Skip to main content

polydat_core/compile/
assembly.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Programmatic assembly API for building Polydat Kernels.
5//!
6//! The assembler validates wiring and types, auto-inserts edge adapters,
7//! topologically sorts nodes, and builds a kernel on any engine: a host
8//! adds nodes and wires (or takes the assembler the DSL built from
9//! source) and calls [`PolydatAssembler::compile_kernel`] for the default
10//! engine, [`PolydatAssembler::compile_with`] for a named one, or
11//! [`PolydatAssembler::compile`] for the interpreter kernel as a concrete
12//! type. The `try_compile*` constructors build one engine's kernel as its
13//! concrete type for the differential suites and the ladder.
14
15use std::collections::HashMap;
16
17use crate::ast::SlotShape;
18use crate::ast::{PolydatNode, PortType};
19use crate::compile::closures::{
20    CompiledKernelPull, CompiledKernelPush, CompiledKernelPushPull, CompiledKernelRaw,
21};
22use crate::compile::select::{self, ProvMode};
23use crate::kernel::{PolydatKernel, PolydatProgram, WireSource};
24use crate::library::convert::{F64ToString, U64ToF64, U64ToString};
25use crate::library::json::JsonToStr;
26
27/// A reference to a value in the assembler: either a coordinate or a
28/// node output port.
29#[derive(Debug, Clone)]
30pub enum WireRef {
31    /// A graph input, by name.
32    Input(String),
33    /// A node output: `(node_name, output_port_index)`.
34    Node(String, usize),
35}
36
37impl WireRef {
38    /// Convenience: reference the first (or only) output of a named node.
39    pub fn node(name: impl Into<String>) -> Self {
40        WireRef::Node(name.into(), 0)
41    }
42
43    /// Reference a specific output port of a named node.
44    pub fn node_port(name: impl Into<String>, port: usize) -> Self {
45        WireRef::Node(name.into(), port)
46    }
47
48    /// Reference a graph input by name.
49    pub fn input(name: impl Into<String>) -> Self {
50        WireRef::Input(name.into())
51    }
52}
53
54struct PendingNode {
55    name: String,
56    node: Box<dyn PolydatNode>,
57    inputs: Vec<WireRef>,
58}
59
60/// Errors that can occur during assembly.
61#[derive(Debug)]
62pub enum AssemblyError {
63    /// A wire reference names no node output or input.
64    UnknownWire(String),
65    /// A wire's type does not match the port it feeds and no adapter heals it.
66    TypeMismatch {
67        /// The producing node.
68        from_node: String,
69        /// Its output port index.
70        from_port: usize,
71        /// The output's type.
72        from_type: PortType,
73        /// The consuming node.
74        to_node: String,
75        /// Its input port index.
76        to_port: usize,
77        /// The type the port requires.
78        to_type: PortType,
79    },
80    /// Two nodes were added under one name.
81    DuplicateNode(String),
82    /// The wiring has a cycle.
83    CycleDetected,
84    /// A node was wired with the wrong number of inputs.
85    ArityMismatch {
86        /// The node.
87        node_name: String,
88        /// Inputs its signature takes.
89        expected: usize,
90        /// Inputs it was given.
91        got: usize,
92    },
93    /// A compile-constant step could not be computed; see
94    /// [`KernelError::ConstantFold`], which this becomes at the kernel
95    /// boundary. Carried here so the interpreter's build path, which
96    /// speaks `AssemblyError`, reports the same kind as the compiled
97    /// engines do rather than folding it into `Other`.
98    ConstantFold(String),
99    /// A `const` binding could not be computed when the kernel was
100    /// initialized; see [`KernelError::ConstInit`], which this becomes at
101    /// the kernel boundary.
102    ConstInit {
103        /// The const.
104        name: String,
105        /// Why its expression failed, as the node reported it.
106        reason: String,
107    },
108    /// `CompileOptions::input_variance` is `Error` and these inputs'
109    /// types were inferred rather than declared, as `(name, inferred
110    /// type)` (input_variance.md §4).
111    OpenInputs(Vec<(String, PortType)>),
112    /// Catch-all for errors from downstream phases (e.g., strict mode).
113    Other(String),
114}
115
116impl std::fmt::Display for AssemblyError {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        match self {
119            AssemblyError::UnknownWire(name) => {
120                write!(f, "unknown wire: '{name}'\n\n")?;
121                writeln!(f, "  No node output or coordinate named '{name}' exists.")?;
122                write!(
123                    f,
124                    "  Check spelling, or add a node that produces this output."
125                )
126            }
127            AssemblyError::TypeMismatch {
128                from_node,
129                from_port,
130                from_type,
131                to_node,
132                to_port,
133                to_type,
134            } => {
135                writeln!(
136                    f,
137                    "type mismatch: cannot connect {from_type} output to {to_type} input"
138                )?;
139                writeln!(f)?;
140                writeln!(
141                    f,
142                    "  {from_node} [{from_port}]  ──({from_type})──▶  {to_node} [{to_port}] expects {to_type}"
143                )?;
144                writeln!(f)?;
145                // Suggest auto-adapters that exist
146                let suggestion = match (from_type, to_type) {
147                    (PortType::U64, PortType::Str) => {
148                        Some("This should auto-convert. If you see this, file a bug.")
149                    }
150                    (PortType::F64, PortType::Str) => {
151                        Some("This should auto-convert. If you see this, file a bug.")
152                    }
153                    (PortType::U64, PortType::F64) => {
154                        Some("This should auto-convert. If you see this, file a bug.")
155                    }
156                    (PortType::U64, PortType::Bytes) => {
157                        Some("Add u64_to_bytes() between them to convert.")
158                    }
159                    (PortType::Str, PortType::Bytes) => {
160                        Some("String cannot be directly used as bytes.")
161                    }
162                    (PortType::U64, PortType::Json) => {
163                        Some("Add to_json() between them to wrap as JSON.")
164                    }
165                    (PortType::Str, PortType::Json) => {
166                        Some("Add str_to_json() to parse the string as JSON.")
167                    }
168                    (PortType::Bytes, PortType::Str) => {
169                        Some("Add to_hex() or to_base64() to convert bytes to string.")
170                    }
171                    (PortType::Bytes, PortType::U64) => {
172                        Some("Bytes cannot be directly converted to u64.")
173                    }
174                    _ => None,
175                };
176                if let Some(hint) = suggestion {
177                    write!(f, "  Hint: {hint}")?;
178                }
179                Ok(())
180            }
181            AssemblyError::DuplicateNode(name) => {
182                write!(f, "duplicate node name: '{name}'\n\n")?;
183                write!(f, "  Two nodes cannot share the same name.")
184            }
185            AssemblyError::CycleDetected => {
186                write!(f, "cycle detected in DAG\n\n")?;
187                writeln!(
188                    f,
189                    "  The graph contains a loop. Polydat graphs must be acyclic"
190                )?;
191                write!(f, "  (data flows in one direction only).")
192            }
193            AssemblyError::ArityMismatch {
194                node_name,
195                expected,
196                got,
197            } => {
198                write!(f, "wrong number of inputs for '{node_name}'\n\n")?;
199                writeln!(f, "  Expected {expected} input(s), but got {got}.")?;
200                if *got < *expected {
201                    write!(f, "  Connect more wires to this node's input ports.")
202                } else {
203                    write!(f, "  Disconnect extra wires from this node.")
204                }
205            }
206            AssemblyError::ConstantFold(msg) => write!(
207                f,
208                "a value this program computes at build could not be computed: {msg}"
209            ),
210            AssemblyError::ConstInit { name, reason } => write!(
211                f,
212                "the const '{name}' could not be computed when the kernel was initialized: {reason}"
213            ),
214            AssemblyError::OpenInputs(inputs) => {
215                writeln!(
216                    f,
217                    "these inputs' types were inferred, not declared, and input variance \
218                     is set to refuse them:"
219                )?;
220                for (name, ty) in inputs {
221                    writeln!(f, "  {name} (inferred {ty})")?;
222                }
223                write!(
224                    f,
225                    "Declare each one's type (`extern name: <type>`), or set \
226                     `input_variance` to `Warn` or `Info` to convert what is written to them."
227                )
228            }
229            AssemblyError::Other(msg) => write!(f, "{msg}"),
230        }
231    }
232}
233
234impl std::error::Error for AssemblyError {}
235
236/// Validated, topologically sorted intermediate form.
237pub(crate) struct ResolvedDag {
238    /// Nodes in topological order.
239    pub(crate) nodes: Vec<Box<dyn PolydatNode>>,
240    /// Per-node wiring (in topological order).
241    pub(crate) wiring: Vec<Vec<WireSource>>,
242    /// All input definitions (coordinates + captures).
243    pub(crate) input_defs: Vec<crate::kernel::InputDef>,
244    /// Number of coordinate inputs.
245    pub(crate) coord_count: usize,
246    /// Output name → (node_index_in_sorted, output_port_index).
247    pub(crate) output_map: HashMap<String, (usize, usize)>,
248    /// Output names in declaration order.
249    pub(crate) output_order: Vec<String>,
250    /// Source text for diagnostics.
251    pub(crate) source: String,
252    /// Diagnostic context.
253    pub(crate) context: String,
254    /// Output binding modifiers.
255    pub(crate) output_modifiers: HashMap<String, crate::dsl::ast::BindingModifier>,
256    /// Names declared with `init` (SRD 11 §"Init Binding Contract").
257    pub(crate) const_outputs: std::collections::HashSet<String>,
258    /// The const bindings a kernel initializes, in dependency order.
259    pub(crate) const_inits: Vec<crate::kernel::ConstInit>,
260    /// The cursors the program declares.
261    pub(crate) cursor_schemas: Vec<crate::iteration::source::SourceSchema>,
262    /// The compile ledger every program built from this graph records in.
263    pub(crate) ledger: std::sync::Arc<crate::kernel::CompileLedger>,
264}
265
266impl ResolvedDag {
267    /// Coordinate input names (for P2/P3 kernels that use positional u64 buffers).
268    fn input_names(&self) -> Vec<String> {
269        self.input_defs[..self.coord_count]
270            .iter()
271            .map(|d| d.name.clone())
272            .collect()
273    }
274}
275
276/// Per-port slot layout for compiled kernels
277/// (type_system_alignment.md §6). Each port occupies
278/// `PortType::slot_width()` consecutive buffer slots: an immediate
279/// is one slot; a 128-bit value or a `Ref2` pair is two.
280struct SlotLayout {
281    /// Per kernel input: first slot index.
282    input_starts: Vec<usize>,
283    /// Total slots occupied by kernel inputs.
284    coord_slots: usize,
285    /// Per node, per output port: first slot index.
286    port_offsets: Vec<Vec<usize>>,
287    /// Total buffer length.
288    total_slots: usize,
289}
290
291fn slot_layout(resolved: &ResolvedDag) -> SlotLayout {
292    let mut input_starts = Vec::with_capacity(resolved.coord_count);
293    let mut next = 0usize;
294    for d in &resolved.input_defs {
295        input_starts.push(next);
296        next += d.port_type.slot_width();
297    }
298    let coord_slots = next;
299    let mut port_offsets: Vec<Vec<usize>> = Vec::with_capacity(resolved.nodes.len());
300    for node in &resolved.nodes {
301        let mut po = Vec::with_capacity(node.meta().outs.len());
302        for out in &node.meta().outs {
303            po.push(next);
304            next += out.typ.slot_width();
305        }
306        port_offsets.push(po);
307    }
308    SlotLayout {
309        input_starts,
310        coord_slots,
311        port_offsets,
312        total_slots: next,
313    }
314}
315
316/// Compiled-op selection for one node: a copy step inline, then the
317/// pure-scalar `compiled_u64` (cheapest dispatch), then the slot kit
318/// for every other shape (type_system_alignment.md §4,
319/// compiled_handles.md §3), else `None` → typed-eval
320/// fallback. `wire_types` is the type of each wire input.
321fn node_step_op(
322    node: &dyn crate::ast::PolydatNode,
323    wire_types: &[PortType],
324) -> Option<(
325    crate::compile::closures::StepOp,
326    Vec<crate::ast::ScratchElem>,
327)> {
328    // A plain copy (`identity`, a `__port_` passthrough): an inline
329    // slot copy of an immediate; a `Ref2` value is copied into the
330    // step's own scratch, since a pair is never forwarded (axiom S3).
331    let meta = node.meta();
332    if (meta.name == "identity" || meta.name.starts_with("__port_")) && meta.outs.len() == 1 {
333        return Some(match meta.outs[0].typ.slot_color() {
334            crate::ast::SlotColor::Ref2 => {
335                let kit = ref_copy_kit(meta.outs[0].typ)?;
336                (crate::compile::closures::StepOp::Slot(kit.op), kit.scratch)
337            }
338            _ => (crate::compile::closures::StepOp::Copy, Vec::new()),
339        });
340    }
341    if let Some(op) = node.compiled_u64() {
342        return Some((crate::compile::closures::StepOp::U64(op), Vec::new()));
343    }
344    node.compiled_slot(
345        wire_types,
346        crate::compile::select::Engine::Closures(crate::compile::select::Provenance::Auto),
347    )
348    .map(|kit| (crate::compile::closures::StepOp::Slot(kit.op), kit.scratch))
349}
350
351/// Axiom S9(a): the `(first slot, scratch index)` pairs of a step's
352/// scratch-backed `Ref2` outputs. A kit's scratch entries pair with
353/// the step's `Ref2` output ports in port order, skipping the entries
354/// that publish no pair (a native cone's slot buffer, a render's body
355/// kernels, a node's own state); a `Ref2` output beyond the kit's publishing entries is
356/// not scratch-backed (a pair into interned bytes) and is validated by
357/// nothing. `base` is the index of the kit's first entry in the
358/// kernel's scratch. A kit with more publishing entries than the step
359/// has `Ref2` outputs is a macro or builder bug, caught at
360/// construction (axiom S3).
361pub(crate) fn scratch_pairs(
362    name: &str,
363    ref_starts: &[usize],
364    scratch: &[crate::ast::ScratchElem],
365    base: usize,
366) -> Vec<(usize, usize)> {
367    use crate::ast::ScratchElem;
368    let publishing: Vec<usize> = scratch
369        .iter()
370        .enumerate()
371        .filter(|(_, e)| {
372            !matches!(
373                e,
374                ScratchElem::Slots | ScratchElem::Kernels | ScratchElem::State
375            )
376        })
377        .map(|(k, _)| base + k)
378        .collect();
379    assert!(
380        publishing.len() <= ref_starts.len(),
381        "slot-op step '{name}' declares {} publishing scratch entries for {} Ref output ports",
382        publishing.len(),
383        ref_starts.len()
384    );
385    ref_starts.iter().copied().zip(publishing).collect()
386}
387
388/// The compiled form of a copy of a `Ref2` value (`identity`, the
389/// compiler's `__port_<name>` passthrough, a type assertion): the pair
390/// is never forwarded (axiom S3), so the elements are copied into this
391/// step's own scratch entry and its pair is published. `None` for an
392/// immediate color, which is copied inline.
393pub(crate) fn ref_copy_kit(ty: PortType) -> Option<crate::ast::CompiledSlotKit> {
394    use crate::ast::ScratchBuf;
395    let elem = ty.scratch_elem()?;
396    Some(crate::ast::CompiledSlotKit {
397        scratch: vec![elem],
398        op: Box::new(
399            move |inputs: &[u64], outputs: &mut [u64], scratch: &mut [ScratchBuf]| {
400                let (p, n) = (inputs[0] as usize, inputs[1] as usize);
401                macro_rules! copy_into {
402                    ($v:expr, $t:ty) => {{
403                        $v.clear();
404                        // SAFETY: the pair was published by the producing
405                        // step into storage alive until it reruns (axioms
406                        // S3, S4), and the layout typed it `$t`.
407                        $v.extend_from_slice(unsafe {
408                            std::slice::from_raw_parts(p as *const $t, n)
409                        });
410                    }};
411                }
412                match &mut scratch[0] {
413                    ScratchBuf::Str(v) | ScratchBuf::Bytes(v) => copy_into!(v, u8),
414                    ScratchBuf::F32(v) => copy_into!(v, f32),
415                    ScratchBuf::F64(v) => copy_into!(v, f64),
416                    ScratchBuf::F16(v) => copy_into!(v, half::f16),
417                    ScratchBuf::I8(v) => copy_into!(v, i8),
418                    ScratchBuf::I16(v) => copy_into!(v, i16),
419                    ScratchBuf::I32(v) => copy_into!(v, i32),
420                    ScratchBuf::I64(v) => copy_into!(v, i64),
421                    ScratchBuf::Value(v) => {
422                        v.clear();
423                        if n > 0 {
424                            // SAFETY: as above; a value pair names one `Value`.
425                            v.push(unsafe { (*(p as *const crate::ast::Value)).clone() });
426                        }
427                    }
428                    ScratchBuf::Slots(_) | ScratchBuf::Kernels(_) | ScratchBuf::State(_) => {
429                        unreachable!("a copy owns only a value entry")
430                    }
431                }
432                let (ptr, len) = scratch[0].ptr_len();
433                outputs[0] = ptr;
434                outputs[1] = len;
435            },
436        ),
437    })
438}
439
440/// The compiled form of `identity`, synthesized by the builder: a slot
441/// copy, for every port color except `Ref2`, which
442/// [`ref_copy_kit`] carries. The node itself is polymorphic over
443/// `Value` and so has no kit of its own; the builder knows the
444/// resolved port type and can supply one.
445pub(crate) fn identity_op(node: &dyn crate::ast::PolydatNode) -> Option<crate::ast::CompiledU64Op> {
446    let meta = node.meta();
447    if meta.name != "identity" || meta.outs.len() != 1 {
448        return None;
449    }
450    if meta.outs[0].typ.slot_color() == crate::ast::SlotColor::Ref2 {
451        return None;
452    }
453    Some(Box::new(|inputs: &[u64], outputs: &mut [u64]| {
454        outputs.copy_from_slice(inputs)
455    }))
456}
457
458impl SlotLayout {
459    /// Flattened input slot list for one node: every wire source
460    /// contributes its full width, in port order.
461    fn input_slots(&self, resolved: &ResolvedDag, node_idx: usize) -> Vec<usize> {
462        let mut slots = Vec::new();
463        for source in &resolved.wiring[node_idx] {
464            let (start, w) = match source {
465                WireSource::Input(c) => (
466                    self.input_starts.get(*c).copied().unwrap_or(*c),
467                    resolved
468                        .input_defs
469                        .get(*c)
470                        .map(|d| d.port_type.slot_width())
471                        .unwrap_or(1),
472                ),
473                WireSource::NodeOutput(u, p) => (
474                    self.port_offsets[*u][*p],
475                    resolved.nodes[*u].meta().outs[*p].typ.slot_width(),
476                ),
477            };
478            slots.extend(start..start + w);
479        }
480        slots
481    }
482
483    /// Flattened output slot list for one node.
484    fn output_slots(&self, resolved: &ResolvedDag, node_idx: usize) -> Vec<usize> {
485        let mut slots = Vec::new();
486        for (p, out) in resolved.nodes[node_idx].meta().outs.iter().enumerate() {
487            let start = self.port_offsets[node_idx][p];
488            slots.extend(start..start + out.typ.slot_width());
489        }
490        slots
491    }
492
493    /// Output name → first slot of the named port.
494    fn named_outputs(&self, resolved: &ResolvedDag) -> HashMap<String, usize> {
495        resolved
496            .output_map
497            .iter()
498            .map(|(name, (n, p))| (name.clone(), self.port_offsets[*n][*p]))
499            .collect()
500    }
501
502    /// Axiom S2: per-slot mask of the slots raw readers must refuse,
503    /// over the whole buffer — kernel inputs and node outputs alike.
504    /// Both slots of a Ref pair are masked, since their bits are an
505    /// address and a length rather than a value; only a typed accessor
506    /// or a boundary decode may read them.
507    fn ref_slot_mask(&self, resolved: &ResolvedDag) -> Vec<bool> {
508        use crate::ast::SlotColor;
509        let mut mask = vec![false; self.total_slots];
510        let mut mark = |start: usize, color: SlotColor| match color {
511            SlotColor::Ref2 => {
512                mask[start] = true;
513                mask[start + 1] = true;
514            }
515            SlotColor::Imm1 | SlotColor::Imm2 => {}
516        };
517        for (i, d) in resolved.input_defs.iter().enumerate() {
518            mark(self.input_starts[i], d.port_type.slot_color());
519        }
520        for (n, node) in resolved.nodes.iter().enumerate() {
521            for (p, out) in node.meta().outs.iter().enumerate() {
522                mark(self.port_offsets[n][p], out.typ.slot_color());
523            }
524        }
525        mask
526    }
527
528    /// First slot of each Ref2-colored output port of one node,
529    /// in port order — pairs with the node's `CompiledSlotKit`
530    /// scratch entries (axiom S3).
531    fn ref_output_starts(&self, resolved: &ResolvedDag, node_idx: usize) -> Vec<usize> {
532        resolved.nodes[node_idx]
533            .meta()
534            .outs
535            .iter()
536            .enumerate()
537            .filter(|(_, out)| out.typ.slot_color() == crate::ast::SlotColor::Ref2)
538            .map(|(p, _)| self.port_offsets[node_idx][p])
539            .collect()
540    }
541
542    /// Expand per-INPUT dependent-step lists to per-SLOT lists so
543    /// the kernels' slot-indexed dirty tracking / changed-mask
544    /// bits stay coherent under multi-slot inputs (every slot of
545    /// one input shares that input's dependents). Identity for
546    /// all-scalar inputs.
547    fn expand_dependents(&self, resolved: &ResolvedDag, deps: &[Vec<usize>]) -> Vec<Vec<usize>> {
548        let mut out = Vec::with_capacity(self.coord_slots);
549        for (i, d) in resolved.input_defs.iter().enumerate() {
550            for _ in 0..d.port_type.slot_width() {
551                out.push(deps.get(i).cloned().unwrap_or_default());
552            }
553        }
554        out
555    }
556}
557
558/// Builder for assembling a Polydat Kernel programmatically.
559pub struct PolydatAssembler {
560    /// All input definitions. Coordinates come first (indices 0..coord_count).
561    input_defs: Vec<crate::kernel::InputDef>,
562    /// How many of the inputs are coordinates.
563    coord_count: usize,
564    nodes: Vec<PendingNode>,
565    /// Output declarations in insertion order.
566    output_order: Vec<String>,
567    outputs: HashMap<String, WireRef>,
568    /// Original source text for diagnostics. Set by the DSL compiler.
569    source: String,
570    /// Diagnostic context (e.g., "workload.yaml bindings").
571    context: String,
572    /// Binding modifiers for named outputs.
573    output_modifiers: HashMap<String, crate::dsl::ast::BindingModifier>,
574    /// The const bindings a kernel initializes, in dependency order.
575    const_inits: Vec<crate::kernel::ConstInit>,
576    /// A template's build is not initialized: its inputs come from a
577    /// binder, and each kernel bound from it is initialized then.
578    pub(crate) template: bool,
579    /// Names declared with the `const` keyword. Subject to the
580    /// init-binding contract (SRD 11 §"Init Binding Contract").
581    const_outputs: std::collections::HashSet<String>,
582    /// SRD 15 §"Strict Wire Mode": when true, the resolver
583    /// auto-inserts `AssertValue` nodes in front of every wire
584    /// input whose declared `Port.constraint` can't be statically
585    /// proven satisfied by the source.
586    pub(crate) strict_values: bool,
587    /// SRD 15: when true, the resolver auto-inserts `AssertType`
588    /// nodes in front of wires where the source's runtime variant
589    /// can't be statically proven to match the sink's declared
590    /// `PortType`. Today this is mainly latent — the type system
591    /// already proves variants match for nearly every wire — so
592    /// the flag exists for forward compatibility with dynamic
593    /// JSON navigation, `Ext` unwraps, and cross-adapter values.
594    pub(crate) strict_types: bool,
595    /// Strict mode: an implicit type coercion is refused at wire
596    /// resolution, and a config wire fed from a cycle-time source, a
597    /// nondeterministic node no `volatile` output acknowledges, and a
598    /// binding nothing reads are refused at build, on every engine.
599    pub(crate) strict: bool,
600    /// What `resolve` does with an input whose type was inferred
601    /// (input_variance.md §4).
602    input_variance: crate::dsl::compile::InputVariance,
603    /// How much of the interpreter's graph `compile()` fuses into native
604    /// cones; `None` is [`JitMode::Auto`](crate::compile::cone::JitMode).
605    /// `compile_with(Engine::Interpreter(mode))` takes its mode from the
606    /// engine.
607    pub(crate) jit_mode: Option<crate::compile::cone::JitMode>,
608    /// The compile ledger every program built from this assembler
609    /// records in: a fresh one unless the compiler hands down the
610    /// tree's.
611    pub(crate) ledger: std::sync::Arc<crate::kernel::CompileLedger>,
612    /// The cursors the program declares (engines.md §3.5), set
613    /// by the DSL compiler so every kernel built from this assembler
614    /// knows them.
615    cursor_schemas: Vec<crate::iteration::source::SourceSchema>,
616}
617
618/// `(coord_slots, total_slots, steps, named outputs, ref-slot
619/// mask)` — the Phase-2 compiled layout shared by the closure
620/// kernel builders.
621type P2Layout = (
622    usize,
623    usize,
624    Vec<crate::compile::closures::P2Step>,
625    HashMap<String, usize>,
626    Vec<bool>,
627    crate::compile::closures::P2Extras,
628);
629
630/// `(coord_slots, total_slots, JIT steps, named outputs, scratch,
631/// volatile steps)` — the JIT compiled layout shared by the native
632/// kernel builders; the scratch is what a state owns for the steps'
633/// kits, with each step's entries placed, and the volatile steps are
634/// the never-current ones (runtime_model.md, R1.v).
635#[cfg(feature = "jit")]
636type JitLayout = (
637    usize,
638    usize,
639    Vec<(crate::compile::jit::JitOp, Vec<usize>, Vec<usize>)>,
640    HashMap<String, usize>,
641    crate::compile::jit::ScratchPlan,
642    Vec<usize>,
643);
644
645impl PolydatAssembler {
646    /// Create a new assembler with the given coordinate names.
647    pub fn new(input_names: Vec<String>) -> Self {
648        let coord_count = input_names.len();
649        let input_defs: Vec<crate::kernel::InputDef> = input_names
650            .into_iter()
651            .map(|name| crate::kernel::InputDef {
652                name,
653                default: crate::ast::Value::U64(0),
654                port_type: crate::ast::PortType::U64,
655                kind: crate::kernel::InputKind::Coordinate,
656                // Declared by `set_input_type` when the program types it.
657                type_origin: crate::kernel::TypeOrigin::Inferred,
658                converts_to: None,
659            })
660            .collect();
661        Self {
662            input_defs,
663            coord_count,
664            nodes: Vec::new(),
665            output_order: Vec::new(),
666            outputs: HashMap::new(),
667            source: String::new(),
668            context: "(assembler)".into(),
669            output_modifiers: HashMap::new(),
670            const_inits: Vec::new(),
671            template: false,
672            const_outputs: std::collections::HashSet::new(),
673            strict_values: false,
674            strict_types: false,
675            strict: false,
676            input_variance: crate::dsl::compile::InputVariance::Fixed,
677            jit_mode: None,
678            cursor_schemas: Vec::new(),
679            ledger: crate::kernel::CompileLedger::new(),
680        }
681    }
682
683    /// Record the cursors the program declares, with the partitions the
684    /// compiler resolved for each. Every kernel built from this
685    /// assembler reports them through `cursor_schemas` and narrows one
686    /// through `set_cursor`.
687    pub fn set_cursor_schemas(&mut self, schemas: Vec<crate::iteration::source::SourceSchema>) {
688        self.cursor_schemas = schemas;
689    }
690
691    /// The cursors the program declares.
692    pub fn cursor_schemas(&self) -> &[crate::iteration::source::SourceSchema] {
693        &self.cursor_schemas
694    }
695
696    /// Enable strict-wire-mode auto-insertion of value/type assertion
697    /// nodes (SRD 15 §"Strict Wire Mode"). Off by default — the
698    /// caller (compiler / DSL pragma extractor) opts in.
699    pub fn set_strict_wires(&mut self, strict_types: bool, strict_values: bool) {
700        self.strict_types = strict_types;
701        self.strict_values = strict_values;
702    }
703
704    /// Strict mode, on every engine this assembler builds for: an
705    /// implicit type coercion, a config wire fed from a cycle-time
706    /// source, a nondeterministic node no `volatile` output
707    /// acknowledges, and a binding nothing reads are errors. Off by
708    /// default; the DSL sets it from its `strict` option.
709    pub fn set_strict(&mut self, strict: bool) {
710        self.strict = strict;
711    }
712
713    /// Override the engine-mix mode for this compile (SRD-105).
714    /// Unset means `JitMode::Auto`.
715    pub fn set_jit_mode(&mut self, mode: crate::compile::cone::JitMode) {
716        self.jit_mode = Some(mode);
717    }
718
719    /// Set the source text and diagnostic context for this assembler.
720    /// Called by the DSL compiler to attach the original Polydat source.
721    pub fn set_context(&mut self, source: &str, context: &str) {
722        self.source = source.to_string();
723        self.context = context.to_string();
724    }
725
726    /// Add a node to the assembler with the given name and input wiring.
727    pub fn add_node(
728        &mut self,
729        name: impl Into<String>,
730        node: Box<dyn PolydatNode>,
731        inputs: Vec<WireRef>,
732    ) -> &mut Self {
733        self.nodes.push(PendingNode {
734            name: name.into(),
735            node,
736            inputs,
737        });
738        self
739    }
740
741    /// Set the binding modifier for a named output.
742    pub fn set_output_modifier(&mut self, name: &str, modifier: crate::dsl::ast::BindingModifier) {
743        if modifier != crate::dsl::ast::BindingModifier::NONE {
744            self.output_modifiers.insert(name.to_string(), modifier);
745        }
746    }
747
748    /// Record how the `const` bindings are initialized, in dependency
749    /// order.
750    pub(crate) fn set_const_inits(&mut self, inits: Vec<crate::kernel::ConstInit>) {
751        self.const_inits = inits;
752    }
753
754    /// Mark an output as declared with the `const` keyword. Compile-
755    /// time and scope-activation checks (SRD 11 §"Init Binding
756    /// Contract") read this set to enforce const-like-constraint
757    /// semantics on the binding.
758    pub fn mark_const_output(&mut self, name: &str) {
759        self.const_outputs.insert(name.to_string());
760    }
761
762    /// How many nodes the graph holds so far.
763    pub fn node_count(&self) -> usize {
764        self.nodes.len()
765    }
766
767    /// Designate a wire as a named output variate.
768    pub fn add_output(&mut self, name: impl Into<String>, wire: WireRef) -> &mut Self {
769        let name = name.into();
770        if !self.outputs.contains_key(&name) {
771            self.output_order.push(name.clone());
772        }
773        self.outputs.insert(name, wire);
774        self
775    }
776
777    /// Declare an additional named input.
778    ///
779    /// Added after coordinate inputs. Nodes wire to it via
780    /// `WireRef::input(name)` — same as coordinate inputs.
781    /// `kind` controls the lifecycle classification used by the
782    /// init-binding contract (see
783    /// `crates/polydat/docs/design/evaluation_model.md`
784    /// §"Effectively-Const Nodes"): `IterationExtern` for slots
785    /// populated by `materialize_wiring_from_outer`, `ExternalWrite` for slots
786    /// written by capture extraction.
787    pub fn add_input(
788        &mut self,
789        name: impl Into<String>,
790        default: crate::ast::Value,
791        port_type: crate::ast::PortType,
792        kind: crate::kernel::InputKind,
793    ) -> &mut Self {
794        self.input_defs.push(crate::kernel::InputDef {
795            name: name.into(),
796            default,
797            port_type,
798            kind,
799            type_origin: crate::kernel::TypeOrigin::Declared,
800            converts_to: None,
801        });
802        self
803    }
804
805    /// Override a declared input's port type. `new` seeds every
806    /// `input_names` entry with `PortType::U64`; this applies the type
807    /// from an `input <name>: <type>` declaration. No-op if the input
808    /// isn't present.
809    pub fn set_input_type(&mut self, name: &str, port_type: crate::ast::PortType) {
810        if let Some(d) = self.input_defs.iter_mut().find(|d| d.name == name) {
811            d.port_type = port_type;
812            d.type_origin = crate::kernel::TypeOrigin::Declared;
813        }
814    }
815
816    /// Record how input `name`'s type was established: the compiler
817    /// marks an auto-extern `Inferred`, so `input_variance` can open it
818    /// (input_variance.md §3). `add_input` records `Declared`.
819    pub fn set_input_origin(&mut self, name: &str, origin: crate::kernel::TypeOrigin) {
820        if let Some(d) = self.input_defs.iter_mut().find(|d| d.name == name) {
821            d.type_origin = origin;
822        }
823    }
824
825    /// What `resolve` does with an input whose type was inferred.
826    pub fn set_input_variance(&mut self, variance: crate::dsl::compile::InputVariance) {
827        self.input_variance = variance;
828    }
829
830    /// Return the names of all inputs (coordinates + captures).
831    pub fn input_names(&self) -> Vec<&str> {
832        self.input_defs.iter().map(|d| d.name.as_str()).collect()
833    }
834
835    /// Query the output port type of a named node (first output).
836    /// Returns `None` if the node is not found or has no output
837    /// ports; callers surface the absence as a loud diagnostic
838    /// rather than silently substituting a default.
839    pub fn node_output_type(&self, name: &str) -> Option<crate::ast::PortType> {
840        self.nodes
841            .iter()
842            .find(|n| n.name == name)
843            .and_then(|n| n.node.meta().outs.first())
844            .map(|p| p.typ)
845    }
846
847    /// Return the names of declared outputs.
848    pub fn output_names(&self) -> Vec<&str> {
849        self.outputs.keys().map(|s| s.as_str()).collect()
850    }
851
852    /// The node type a named node has, when the name is a node.
853    pub fn node_type_of(&self, name: &str) -> Option<String> {
854        self.nodes
855            .iter()
856            .find(|pn| pn.name == name)
857            .map(|pn| pn.node.meta().name.clone())
858    }
859
860    /// Look up the output port type of a named node.
861    ///
862    /// Returns the first output port's `PortType` if the node exists.
863    pub fn output_type(&self, name: &str) -> Option<PortType> {
864        self.nodes
865            .iter()
866            .find(|pn| pn.name == name)
867            .and_then(|pn| pn.node.meta().outs.first())
868            .map(|port| port.typ)
869    }
870
871    /// Look up the port type of a graph input by name.
872    pub fn input_type(&self, name: &str) -> Option<PortType> {
873        self.input_defs
874            .iter()
875            .find(|d| d.name == name)
876            .map(|d| d.port_type)
877    }
878
879    /// Look up the produced port type of a `WireRef`. Returns `None`
880    /// if the wire's source isn't yet known to the assembler (e.g.
881    /// it points to a not-yet-added node — a bug in the binding
882    /// compiler if it happens).
883    pub fn wire_type(&self, wire: &WireRef) -> Option<PortType> {
884        match wire {
885            WireRef::Input(name) => self.input_type(name),
886            WireRef::Node(name, port_idx) => self
887                .nodes
888                .iter()
889                .find(|pn| &pn.name == name)
890                .and_then(|pn| pn.node.meta().outs.get(*port_idx))
891                .map(|p| p.typ),
892        }
893    }
894
895    /// Validate, resolve, and produce a Phase 1 runtime kernel.
896    pub fn compile(self) -> Result<PolydatKernel, AssemblyError> {
897        self.compile_with_log(None)
898    }
899
900    /// Compile with diagnostic event logging.
901    pub fn compile_with_log(
902        self,
903        mut log: Option<&mut crate::dsl::events::CompileEventLog>,
904    ) -> Result<PolydatKernel, AssemblyError> {
905        let jit_mode = self.jit_mode.unwrap_or_default();
906        let strict = self.strict;
907        let template = self.template;
908        let mut resolved = self.resolve_with_log(log.as_deref_mut())?;
909        let (node_total, output_total) = (resolved.nodes.len(), resolved.output_order.len());
910        crate::compile::cone::extract_jit_cones(&mut resolved, jit_mode);
911        let _coord_names = resolved.input_names();
912        let modifiers = resolved.output_modifiers.clone();
913        let cursors = std::mem::take(&mut resolved.cursor_schemas);
914        let mut kernel = PolydatKernel::new_with_inputs(
915            resolved.nodes,
916            resolved.wiring,
917            resolved.input_defs,
918            resolved.coord_count,
919            resolved.output_map,
920            resolved.output_order,
921            resolved.const_outputs,
922            modifiers,
923            &resolved.source,
924            &resolved.context,
925            log.as_deref_mut(),
926            strict,
927            resolved.ledger.clone(),
928        )?;
929        if !cursors.is_empty() {
930            kernel.set_cursor_schemas(cursors);
931        }
932        kernel.set_cone_mode(jit_mode);
933        kernel.set_const_inits(resolved.const_inits);
934        Self::log_summary(log, node_total, output_total);
935        if template {
936            return Ok(kernel);
937        }
938        crate::kernel::Kernel::init(&mut kernel).map_err(|e| match e {
939            KernelError::ConstInit { name, reason } => AssemblyError::ConstInit { name, reason },
940            other => AssemblyError::ConstInit {
941                name: String::new(),
942                reason: other.to_string(),
943            },
944        })?;
945        Ok(kernel)
946    }
947
948    /// Strict mode's build-time refusals on a resolved graph, the ones
949    /// the interpreter's fold makes: what a compiled engine checks
950    /// before it builds, so strict means the same thing on every engine.
951    fn refuse_strict(resolved: &ResolvedDag) -> Result<(), AssemblyError> {
952        let classes = PolydatProgram::classify_lifecycle(
953            &resolved.nodes,
954            &resolved.wiring,
955            &resolved.input_defs,
956            &resolved.output_map,
957            &resolved.output_modifiers,
958        );
959        let is_init: Vec<bool> = classes
960            .lifecycle
961            .iter()
962            .map(|lc| *lc == crate::kernel::EvalLifecycle::CompileConst)
963            .collect();
964        match PolydatProgram::strict_violation(
965            &resolved.nodes,
966            &resolved.wiring,
967            &is_init,
968            &resolved.output_map,
969            &resolved.output_modifiers,
970        ) {
971            Some(violation) => Err(AssemblyError::Other(violation)),
972            None => Ok(()),
973        }
974    }
975
976    /// A node with no closure form, as a refusal naming the closure
977    /// tier and the reason the layout gave.
978    fn refused_by_closures(reason: String) -> KernelError {
979        KernelError::Refused {
980            engine: Engine::Closures(Provenance::Auto),
981            reason,
982        }
983    }
984
985    /// A node native code cannot run, as a refusal naming the native
986    /// engine and the reason the layout gave.
987    fn refused_by_native(reason: String) -> KernelError {
988        KernelError::Refused {
989            engine: Engine::Native(Provenance::Auto),
990            reason,
991        }
992    }
993
994    /// The same, naming the pure tier: on `Native` a node without a
995    /// native lowering runs its closure, so only the pure tier turns
996    /// that into a refusal, and the error should say which engine
997    /// refused.
998    #[cfg_attr(not(feature = "jit"), allow(dead_code))]
999    fn refused_by_pure_native(reason: String) -> KernelError {
1000        KernelError::Refused {
1001            engine: Engine::PureNative(Provenance::Auto),
1002            reason,
1003        }
1004    }
1005
1006    /// Shared: extract P2 compiled steps + slot layout from resolved DAG.
1007    /// Returns None if any node lacks a compiled form.
1008    fn build_p2_layout(resolved: &ResolvedDag) -> Result<P2Layout, String> {
1009        let layout = slot_layout(resolved);
1010
1011        let mut compiled_ops = Vec::with_capacity(resolved.nodes.len());
1012        let mut extras = crate::compile::closures::P2Extras::default();
1013        for (node_idx, node) in resolved.nodes.iter().enumerate() {
1014            compiled_ops.push(
1015                node_step_op(node.as_ref(), &wire_types_of(resolved, node_idx)).ok_or_else(
1016                    || {
1017                        format!(
1018                            "node '{}' has no compiled form (docs/design/engines.md §8)",
1019                            node.meta().name
1020                        )
1021                    },
1022                )?,
1023            );
1024        }
1025        extras.externs = crate::compile::externs::Externs::new(
1026            &resolved.input_defs,
1027            resolved.coord_count,
1028            &layout.input_starts,
1029            &resolved.cursor_schemas,
1030            &shared_outputs_of(resolved),
1031            resolved.ledger.clone(),
1032        )?;
1033        extras.externs.set_output_names(&resolved.output_order);
1034        extras
1035            .externs
1036            .set_output_modifiers(&resolved.output_modifiers);
1037        extras.externs.set_const_inits(&resolved.const_inits);
1038        extras.output_types = resolved
1039            .output_map
1040            .iter()
1041            .map(|(name, (n, p))| (name.clone(), resolved.nodes[*n].meta().outs[*p].typ))
1042            .collect();
1043
1044        // The runtime model's lifecycle classification, the one rule the
1045        // interpreter's fold applies, and the provenance the plan is
1046        // derived from.
1047        let classes = PolydatProgram::classify_lifecycle(
1048            &resolved.nodes,
1049            &resolved.wiring,
1050            &resolved.input_defs,
1051            &resolved.output_map,
1052            &resolved.output_modifiers,
1053        );
1054        let inventory = PolydatProgram::compute_node_inventory(&resolved.nodes, &resolved.wiring);
1055        let per_input = PolydatProgram::compute_dependents(
1056            &inventory.input_provenance,
1057            resolved.input_defs.len(),
1058        );
1059        extras.input_dependents = layout.expand_dependents(resolved, &per_input);
1060        extras.attribution = std::sync::Arc::new(Self::attribution_of(resolved));
1061
1062        let mut steps = Vec::with_capacity(resolved.nodes.len());
1063        for (node_idx, (op, scratch)) in compiled_ops.into_iter().enumerate() {
1064            steps.push(crate::compile::closures::P2Step {
1065                name: resolved.nodes[node_idx].meta().name.clone(),
1066                op,
1067                input_slots: layout.input_slots(resolved, node_idx),
1068                output_slots: layout.output_slots(resolved, node_idx),
1069                ref_output_starts: layout.ref_output_starts(resolved, node_idx),
1070                scratch,
1071                accepts_none: resolved.nodes[node_idx].accepts_none_inputs(),
1072                volatile: classes.nondeterministic[node_idx],
1073                constant: classes.lifecycle[node_idx] == crate::kernel::EvalLifecycle::CompileConst,
1074                side: matches!(
1075                    resolved.nodes[node_idx].purity(),
1076                    crate::ast::Purity::SideChannel { .. }
1077                ),
1078            });
1079        }
1080        let output_map = layout.named_outputs(resolved);
1081        let ref_slots = layout.ref_slot_mask(resolved);
1082
1083        Ok((
1084            layout.coord_slots,
1085            layout.total_slots,
1086            steps,
1087            output_map,
1088            ref_slots,
1089            extras,
1090        ))
1091    }
1092
1093    /// Shared: resolve nodes to JIT steps + slot layout.
1094    #[cfg(feature = "jit")]
1095    pub(crate) fn build_jit_layout(resolved: &ResolvedDag) -> Result<JitLayout, String> {
1096        let layout = slot_layout(resolved);
1097
1098        // Every step's scratch entries are placed in the state's
1099        // scratch as the steps are laid out (axiom S3): a reference
1100        // output's pair names its own entry, wherever the step runs.
1101        let mut scratch = crate::compile::jit::ScratchPlan::default();
1102        let mut jit_steps = Vec::new();
1103        for (node_idx, node) in resolved.nodes.iter().enumerate() {
1104            let mut jit_op = crate::compile::jit::classify_node_typed(
1105                node.as_ref(),
1106                &wire_types_of(resolved, node_idx),
1107            );
1108            if matches!(jit_op, crate::compile::jit::JitOp::Fallback) {
1109                return Err(format!(
1110                    "node '{}' has no native form and no kit; pure native code cannot run it",
1111                    node.meta().name
1112                ));
1113            }
1114            let base = scratch.elems.len();
1115            jit_op.place_scratch(base);
1116            let elems = jit_op.scratch_elems().to_vec();
1117            scratch.refs.extend(scratch_pairs(
1118                &node.meta().name,
1119                &layout.ref_output_starts(resolved, node_idx),
1120                &elems,
1121                base,
1122            ));
1123            scratch.elems.extend(elems);
1124            jit_steps.push((
1125                jit_op,
1126                layout.input_slots(resolved, node_idx),
1127                layout.output_slots(resolved, node_idx),
1128            ));
1129        }
1130
1131        let output_map = layout.named_outputs(resolved);
1132        // The runtime model's lifecycle classification, the one rule the
1133        // interpreter's fold applies: a nondeterministic node, or one
1134        // downstream of it, is never current on any engine.
1135        let classes = PolydatProgram::classify_lifecycle(
1136            &resolved.nodes,
1137            &resolved.wiring,
1138            &resolved.input_defs,
1139            &resolved.output_map,
1140            &resolved.output_modifiers,
1141        );
1142        let volatile: Vec<usize> = (0..resolved.nodes.len())
1143            .filter(|&i| classes.nondeterministic[i])
1144            .collect();
1145        Ok((
1146            layout.coord_slots,
1147            layout.total_slots,
1148            jit_steps,
1149            output_map,
1150            scratch,
1151            volatile,
1152        ))
1153    }
1154
1155    /// The slots a pure-P3 kernel's raw readers must refuse and the
1156    /// port type of each named output, for typed decode (SRD 115 §5).
1157    #[cfg(feature = "jit")]
1158    fn jit_slot_info(resolved: &ResolvedDag) -> (Vec<bool>, HashMap<String, PortType>) {
1159        let layout = slot_layout(resolved);
1160        let guard = layout.ref_slot_mask(resolved);
1161        let types = resolved
1162            .output_map
1163            .iter()
1164            .map(|(name, (n, p))| (name.clone(), resolved.nodes[*n].meta().outs[*p].typ))
1165            .collect();
1166        (guard, types)
1167    }
1168
1169    #[cfg(feature = "jit")]
1170    fn jit_push_pull_from(
1171        resolved: ResolvedDag,
1172    ) -> Result<crate::compile::jit::JitKernelPushPull, KernelError> {
1173        let _coord_names = resolved.input_names();
1174        let (coord_count, total_slots, jit_steps, output_map, scratch, volatile) =
1175            Self::build_jit_layout(&resolved).map_err(Self::refused_by_pure_native)?;
1176        let (guard, types) = Self::jit_slot_info(&resolved);
1177        let deps = slot_layout(&resolved).expand_dependents(
1178            &resolved,
1179            &PolydatProgram::compute_dependents(
1180                &PolydatProgram::compute_provenance(&resolved.nodes, &resolved.wiring),
1181                resolved.input_defs.len(),
1182            ),
1183        );
1184        let externs = Self::externs_of(&resolved).map_err(Self::refused_by_pure_native)?;
1185        let attribution = std::sync::Arc::new(Self::attribution_of(&resolved));
1186        let (folded, origin) = Self::constant_steps(&resolved, &jit_steps);
1187        let alone = Self::side_channels(&resolved);
1188        let mut k = crate::compile::jit::compile_jit_push_pull(
1189            coord_count,
1190            total_slots,
1191            jit_steps,
1192            output_map,
1193            resolved.nodes,
1194            deps,
1195            externs,
1196            scratch,
1197            volatile,
1198            alone,
1199        )
1200        .map_err(Self::refused_by_pure_native)?;
1201        k.set_slot_info(guard, types);
1202        k.set_attribution(attribution);
1203        // After the attribution, so a constant that fails at build names
1204        // its node as it would at evaluation.
1205        k.fold_constants(&folded, &origin, total_slots)?;
1206        Ok(k)
1207    }
1208
1209    /// This graph's compile-constant steps, and the program step each
1210    /// one came from. The closure tier and the hybrid run their
1211    /// constant steps out of the step list they keep; the pure tier
1212    /// compiles one function over every step and keeps no list, so its
1213    /// constants are compiled a second time into an entry of their own
1214    /// and run once over the kernel's buffer. Same classification as
1215    /// the other two engines use, from the runtime model's lifecycle.
1216    #[cfg(feature = "jit")]
1217    #[allow(clippy::type_complexity)]
1218    fn constant_steps(
1219        resolved: &ResolvedDag,
1220        jit_steps: &[(crate::compile::jit::JitOp, Vec<usize>, Vec<usize>)],
1221    ) -> (
1222        Vec<(crate::compile::jit::JitOp, Vec<usize>, Vec<usize>)>,
1223        Vec<usize>,
1224    ) {
1225        let classes = PolydatProgram::classify_lifecycle(
1226            &resolved.nodes,
1227            &resolved.wiring,
1228            &resolved.input_defs,
1229            &resolved.output_map,
1230            &resolved.output_modifiers,
1231        );
1232        // One step per node, pushed in node order by `build_jit_layout`,
1233        // so a step's index is its node's.
1234        jit_steps
1235            .iter()
1236            .enumerate()
1237            .filter(|(i, _)| {
1238                classes.lifecycle.get(*i) == Some(&crate::kernel::EvalLifecycle::CompileConst)
1239            })
1240            .map(|(i, s)| (s.clone(), i))
1241            .unzip()
1242    }
1243
1244    /// Per node, and so per pure-native step, whether it is a side
1245    /// channel: such a step is a fusion unit of its own, so it fires
1246    /// when its own inputs change and not whenever a neighbor runs.
1247    #[cfg(feature = "jit")]
1248    fn side_channels(resolved: &ResolvedDag) -> Vec<bool> {
1249        resolved
1250            .nodes
1251            .iter()
1252            .map(|n| matches!(n.purity(), crate::ast::Purity::SideChannel { .. }))
1253            .collect()
1254    }
1255
1256    /// The extern inputs of a resolved graph, at the slots the layout
1257    /// gives them.
1258    fn externs_of(resolved: &ResolvedDag) -> Result<crate::compile::externs::Externs, String> {
1259        let layout = slot_layout(resolved);
1260        let mut externs = crate::compile::externs::Externs::new(
1261            &resolved.input_defs,
1262            resolved.coord_count,
1263            &layout.input_starts,
1264            &resolved.cursor_schemas,
1265            &shared_outputs_of(resolved),
1266            resolved.ledger.clone(),
1267        )?;
1268        externs.set_output_names(&resolved.output_order);
1269        externs.set_output_modifiers(&resolved.output_modifiers);
1270        externs.set_const_inits(&resolved.const_inits);
1271        Ok(externs)
1272    }
1273
1274    /// Pure native code, raw; see [`Self::try_compile_pure_jit`].
1275    #[doc(hidden)]
1276    #[cfg(feature = "jit")]
1277    pub(crate) fn try_compile_pure_jit_raw(
1278        self,
1279    ) -> Result<crate::compile::jit::JitKernelRaw, KernelError> {
1280        let resolved = self.resolve().map_err(KernelError::Assembly)?;
1281        Self::jit_raw_from(resolved)
1282    }
1283
1284    // ── The typed tier constructors (feature `bench-tiers`) ──────
1285    //
1286    // The same kernels [`Self::compile_slots`] builds, returned as
1287    // their own types instead of `Box<dyn SlotKernel>`.
1288    //
1289    // There is one contract — [`Kernel`](crate::kernel::Kernel) and the
1290    // [`SlotKernel`](crate::compile::SlotKernel) that extends it — and
1291    // these do not add a second. They change only how a caller *holds*
1292    // it: a boxed kernel dispatches, a named one monomorphizes, and
1293    // both are bound by the same trait with the same semantics.
1294    //
1295    // The normative path is `compile_slots`, which picks an engine from
1296    // a runtime value and therefore cannot return a statically known
1297    // type. Only a caller that knows its tier at compile time can use
1298    // these, and only one kind of caller does: a benchmark measuring a
1299    // tier, which otherwise measures the dispatch instead of the
1300    // kernel. On the engine ladder that difference is about a fifth of
1301    // the native tier's per-cycle cost, which is large enough to hide
1302    // the regressions the ladder exists to catch.
1303    //
1304    // Off by default, so an ordinary build and anything a consumer
1305    // links has exactly one door to a kernel.
1306
1307    /// The closure tier with no provenance, as its own type.
1308    #[cfg(feature = "bench-tiers")]
1309    pub fn compile_closures_raw(
1310        self,
1311    ) -> Result<crate::compile::closures::CompiledKernelRaw, KernelError> {
1312        let resolved = self.resolve_with_log(None)?;
1313        let (coord_count, total_slots, steps, output_map, ref_slots, extras) =
1314            Self::build_p2_layout(&resolved).map_err(Self::refused_by_closures)?;
1315        crate::compile::closures::CompiledKernelRaw::new(
1316            coord_count,
1317            total_slots,
1318            steps,
1319            output_map,
1320            ref_slots,
1321            extras,
1322        )
1323    }
1324
1325    /// The native tier with no provenance, as its own type.
1326    #[cfg(all(feature = "bench-tiers", feature = "jit"))]
1327    pub fn compile_native_raw(
1328        self,
1329    ) -> Result<crate::compile::hybrid::HybridKernelRaw, KernelError> {
1330        let resolved = self.resolve_with_log(None)?;
1331        Ok(Self::hybrid_from(resolved)?.into_raw())
1332    }
1333
1334    /// Pure native code with no provenance, as its own type.
1335    #[cfg(all(feature = "bench-tiers", feature = "jit"))]
1336    pub fn compile_pure_native_raw(self) -> Result<crate::compile::jit::JitKernelRaw, KernelError> {
1337        self.try_compile_pure_jit_raw()
1338    }
1339
1340    /// Where each node lives, for the failure path (A7): its name, the
1341    /// outputs it feeds, and `(first slot, port type)` per input port,
1342    /// so a compiled kernel can report a step's failure as the
1343    /// interpreter reports the node's.
1344    pub(crate) fn attribution_of(resolved: &ResolvedDag) -> crate::compile::Attribution {
1345        let layout = slot_layout(resolved);
1346        let sites = resolved
1347            .nodes
1348            .iter()
1349            .enumerate()
1350            .map(|(node_idx, node)| {
1351                let mut outputs: Vec<String> = resolved
1352                    .output_map
1353                    .iter()
1354                    .filter(|(_, (n, _))| *n == node_idx)
1355                    .map(|(name, _)| name.clone())
1356                    .collect();
1357                outputs.sort();
1358                let inputs = resolved.wiring[node_idx]
1359                    .iter()
1360                    .map(|source| match source {
1361                        WireSource::Input(c) => (
1362                            layout.input_starts.get(*c).copied().unwrap_or(*c),
1363                            resolved
1364                                .input_defs
1365                                .get(*c)
1366                                .map(|d| d.port_type)
1367                                .unwrap_or(PortType::U64),
1368                        ),
1369                        WireSource::NodeOutput(u, p) => (
1370                            layout.port_offsets[*u][*p],
1371                            resolved.nodes[*u].meta().outs[*p].typ,
1372                        ),
1373                    })
1374                    .collect();
1375                crate::compile::NodeSite {
1376                    name: node.meta().name.to_string(),
1377                    outputs,
1378                    inputs,
1379                }
1380            })
1381            .collect();
1382        crate::compile::Attribution {
1383            sites,
1384            context: resolved.context.clone(),
1385        }
1386    }
1387
1388    #[cfg(feature = "jit")]
1389    fn jit_raw_from(
1390        resolved: ResolvedDag,
1391    ) -> Result<crate::compile::jit::JitKernelRaw, KernelError> {
1392        let _coord_names = resolved.input_names();
1393        let (coord_count, total_slots, jit_steps, output_map, scratch, volatile) =
1394            Self::build_jit_layout(&resolved).map_err(Self::refused_by_pure_native)?;
1395        let (guard, types) = Self::jit_slot_info(&resolved);
1396        let externs = Self::externs_of(&resolved).map_err(Self::refused_by_pure_native)?;
1397        let attribution = std::sync::Arc::new(Self::attribution_of(&resolved));
1398        let (folded, origin) = Self::constant_steps(&resolved, &jit_steps);
1399        let alone = Self::side_channels(&resolved);
1400        let mut k = crate::compile::jit::compile_jit_raw_with(
1401            coord_count,
1402            total_slots,
1403            jit_steps,
1404            output_map,
1405            resolved.nodes,
1406            externs,
1407            scratch,
1408            volatile,
1409            alone,
1410        )
1411        .map_err(Self::refused_by_pure_native)?;
1412        k.set_slot_info(guard, types);
1413        k.set_attribution(attribution);
1414        // After the attribution, so a constant that fails at build names
1415        // its node as it would at evaluation.
1416        k.fold_constants(&folded, &origin, total_slots)?;
1417        Ok(k)
1418    }
1419
1420    /// Compile the conservative perfect-ordinal Tier-1 SIMD execution plan.
1421    ///
1422    /// Ordinary `compile()` semantics are unchanged. This explicit surface
1423    /// retains the selected scalar DAG as a fallback and synthesizes a second,
1424    /// register-typed DAG for one named output and driving cursor input.
1425    #[cfg(feature = "jit")]
1426    #[doc(hidden)]
1427    pub fn try_compile_tier1_simd_ordinal(
1428        self,
1429        driving_input: &str,
1430        output: &str,
1431    ) -> Result<
1432        crate::compile::simd_tier1::Tier1SimdExecutor,
1433        crate::compile::simd_tier1::Tier1SimdError,
1434    > {
1435        let resolved = self.resolve().map_err(|error| {
1436            crate::compile::simd_tier1::Tier1SimdError::VectorGraphBuild(error.to_string())
1437        })?;
1438        crate::compile::simd_tier1::compile_tier1_ordinal(resolved, driving_input, output)
1439    }
1440
1441    fn hybrid_from(
1442        resolved: ResolvedDag,
1443    ) -> Result<crate::compile::hybrid::HybridKernel, KernelError> {
1444        let _coord_names = resolved.input_names();
1445        let layout = slot_layout(&resolved);
1446
1447        let output_map = layout.named_outputs(&resolved);
1448        let input_widths: Vec<usize> = resolved
1449            .input_defs
1450            .iter()
1451            .map(|d| d.port_type.slot_width())
1452            .collect();
1453
1454        let ref_slots = layout.ref_slot_mask(&resolved);
1455        let input_types: Vec<PortType> = resolved.input_defs.iter().map(|d| d.port_type).collect();
1456        let externs = Self::externs_of(&resolved).map_err(Self::refused_by_native)?;
1457        let attribution = std::sync::Arc::new(Self::attribution_of(&resolved));
1458        // The runtime model's lifecycle classification, the one rule the
1459        // interpreter's fold applies.
1460        let classes = PolydatProgram::classify_lifecycle(
1461            &resolved.nodes,
1462            &resolved.wiring,
1463            &resolved.input_defs,
1464            &resolved.output_map,
1465            &resolved.output_modifiers,
1466        );
1467        let constant: Vec<bool> = classes
1468            .lifecycle
1469            .iter()
1470            .map(|lc| *lc == crate::kernel::EvalLifecycle::CompileConst)
1471            .collect();
1472        let mut kernel = crate::compile::hybrid::build_hybrid(
1473            &resolved.nodes,
1474            &resolved.wiring,
1475            layout.coord_slots,
1476            layout.total_slots,
1477            &layout.port_offsets,
1478            &layout.input_starts,
1479            &input_widths,
1480            output_map,
1481            ref_slots,
1482            &input_types,
1483            externs,
1484            constant,
1485            classes.nondeterministic,
1486            attribution,
1487        )?;
1488        kernel.retain_nodes(resolved.nodes);
1489        Ok(kernel)
1490    }
1491
1492    /// Internal: validate, resolve wiring, insert adapters, topological sort.
1493    /// Report the compiled form each node has
1494    /// (`CompileEvent::CompileLevelSelected`), a property of the node
1495    /// and its wire types, so the log is the same on every engine
1496    /// (engines.md §7): a native form, a compiled `u64` op, a slot
1497    /// kit, a slot copy, or interpretation only. A node is named by
1498    /// the output it produces when it produces one.
1499    fn log_forms(resolved: &ResolvedDag, log: &mut crate::dsl::events::CompileEventLog) {
1500        for (node_idx, node) in resolved.nodes.iter().enumerate() {
1501            let wire_types = wire_types_of(resolved, node_idx);
1502            // Without the `jit` feature there is no native form to
1503            // report: every node reaches its closure, its slot copy, or
1504            // interpretation, which the arms below name.
1505            #[cfg(feature = "jit")]
1506            let native = !matches!(
1507                crate::compile::jit::classify_node_typed(node.as_ref(), &wire_types),
1508                crate::compile::jit::JitOp::Fallback
1509            );
1510            #[cfg(not(feature = "jit"))]
1511            let native = false;
1512            let level = if native {
1513                "native"
1514            } else {
1515                match node_step_op(node.as_ref(), &wire_types) {
1516                    Some((crate::compile::closures::StepOp::Copy, _)) => "slot copy",
1517                    Some((crate::compile::closures::StepOp::U64(_), _)) => "compiled u64 op",
1518                    Some((crate::compile::closures::StepOp::Slot(_), _)) => "slot kit",
1519                    None => "interpreted",
1520                }
1521            };
1522            let name = resolved
1523                .output_map
1524                .iter()
1525                .find(|(_, (ni, _))| *ni == node_idx)
1526                .map(|(n, _)| n.clone())
1527                .unwrap_or_else(|| node.meta().name.clone());
1528            log.push(crate::dsl::events::CompileEvent::CompileLevelSelected {
1529                node: name,
1530                level: level.to_string(),
1531            });
1532        }
1533    }
1534
1535    /// Close the log with the program's shape
1536    /// (`CompileEvent::Summary`): the resolved node and output counts,
1537    /// the same on every engine, and the constants the build folded,
1538    /// counted from the log itself.
1539    fn log_summary(
1540        log: Option<&mut crate::dsl::events::CompileEventLog>,
1541        nodes: usize,
1542        outputs: usize,
1543    ) {
1544        if let Some(log) = log {
1545            let constants_folded = log
1546                .events()
1547                .iter()
1548                .filter(|e| matches!(e, crate::dsl::events::CompileEvent::ConstantFolded { .. }))
1549                .count();
1550            log.push(crate::dsl::events::CompileEvent::Summary {
1551                nodes,
1552                outputs,
1553                constants_folded,
1554            });
1555        }
1556    }
1557
1558    /// Resolve with no log. Only the pure-native paths take it, and
1559    /// those need code generation, so it is gated as they are.
1560    #[cfg(feature = "jit")]
1561    fn resolve(self) -> Result<ResolvedDag, AssemblyError> {
1562        self.resolve_with_log(None)
1563    }
1564
1565    fn resolve_with_log(
1566        mut self,
1567        mut log: Option<&mut crate::dsl::events::CompileEventLog>,
1568    ) -> Result<ResolvedDag, AssemblyError> {
1569        // Input variance (input_variance.md §4). An input whose type the
1570        // compiler inferred rather than the author declared is *open*:
1571        // by the host's setting it keeps its inferred type, stops the
1572        // build, or takes any value, converted in front of its readers
1573        // by a node placed below. Coordinates are never open: they are
1574        // positioned with `set_inputs` and are always `u64`.
1575        let open: Vec<usize> = self
1576            .input_defs
1577            .iter()
1578            .enumerate()
1579            .filter(|(_, d)| {
1580                d.kind != crate::kernel::InputKind::Coordinate
1581                    && d.type_origin == crate::kernel::TypeOrigin::Inferred
1582                    && d.port_type != PortType::Dyn
1583            })
1584            .map(|(i, _)| i)
1585            .collect();
1586        let variance_level = match self.input_variance {
1587            crate::dsl::compile::InputVariance::Fixed => None,
1588            crate::dsl::compile::InputVariance::Error => {
1589                if !open.is_empty() {
1590                    return Err(AssemblyError::OpenInputs(
1591                        open.iter()
1592                            .map(|&i| {
1593                                (
1594                                    self.input_defs[i].name.clone(),
1595                                    self.input_defs[i].port_type,
1596                                )
1597                            })
1598                            .collect(),
1599                    ));
1600                }
1601                None
1602            }
1603            crate::dsl::compile::InputVariance::Warn => {
1604                Some(crate::dsl::events::EventLevel::Warning)
1605            }
1606            crate::dsl::compile::InputVariance::Info => Some(crate::dsl::events::EventLevel::Info),
1607        };
1608        if variance_level.is_some() {
1609            for &i in &open {
1610                let def = &mut self.input_defs[i];
1611                def.converts_to = Some(def.port_type);
1612                def.port_type = PortType::Dyn;
1613            }
1614        }
1615        // An extern without a default is `None` until the host sets it,
1616        // and every consumer reads `None` through it; the log names each
1617        // one so a host knows what it must set (engines.md §3.3).
1618        // A cursor's slots are `None` until narrowed by design and are
1619        // not externs a host sets by value.
1620        if let Some(log) = log.as_deref_mut() {
1621            let cursor_slot = |name: &str| {
1622                self.cursor_schemas
1623                    .iter()
1624                    .any(|s| name.starts_with(&format!("{}__cursor", s.name)))
1625            };
1626            for def in &self.input_defs {
1627                if matches!(
1628                    def.kind,
1629                    crate::kernel::InputKind::ExternalWrite
1630                        | crate::kernel::InputKind::IterationExtern
1631                ) && def.default == crate::ast::Value::None
1632                    && !cursor_slot(&def.name)
1633                {
1634                    log.push(crate::dsl::events::CompileEvent::ExternWithoutDefault {
1635                        name: def.name.clone(),
1636                        port_type: def.port_type.to_string(),
1637                    });
1638                }
1639            }
1640        }
1641        // Build name → index map for nodes
1642        let mut name_to_idx: HashMap<String, usize> = HashMap::new();
1643        for (i, pn) in self.nodes.iter().enumerate() {
1644            if name_to_idx.contains_key(&pn.name) {
1645                return Err(AssemblyError::DuplicateNode(pn.name.clone()));
1646            }
1647            name_to_idx.insert(pn.name.clone(), i);
1648        }
1649
1650        // Build input name → index map (covers both coords and captures)
1651        let input_to_idx: HashMap<String, usize> = self
1652            .input_defs
1653            .iter()
1654            .enumerate()
1655            .map(|(i, d)| (d.name.clone(), i))
1656            .collect();
1657
1658        // Validate arity
1659        for pn in &self.nodes {
1660            let expected = pn.node.meta().wire_inputs().len();
1661            let got = pn.inputs.len();
1662            if expected != got {
1663                return Err(AssemblyError::ArityMismatch {
1664                    node_name: pn.name.clone(),
1665                    expected,
1666                    got,
1667                });
1668            }
1669        }
1670
1671        let mut all_nodes: Vec<PendingNode> = Vec::new();
1672        let mut all_name_to_idx: HashMap<String, usize> = HashMap::new();
1673        let mut adapter_count = 0usize;
1674        let mut assertion_count = 0usize;
1675        let strict_values = self.strict_values;
1676        let strict_types = self.strict_types;
1677        let strict = self.strict;
1678
1679        for pn in self.nodes {
1680            let idx = all_nodes.len();
1681            all_name_to_idx.insert(pn.name.clone(), idx);
1682            all_nodes.push(pn);
1683        }
1684
1685        let mut resolved_wiring: Vec<Vec<WireSource>> = Vec::new();
1686        // One converter per (input, target type), shared by its readers.
1687        let mut converters: HashMap<(usize, PortType), usize> = HashMap::new();
1688
1689        for node_idx in 0..all_nodes.len() {
1690            let mut node_wiring = Vec::new();
1691
1692            for (port_idx, wire_ref) in all_nodes[node_idx].inputs.clone().iter().enumerate() {
1693                let port = all_nodes[node_idx].node.meta().wire_inputs()[port_idx].clone();
1694                let expected_type = port.typ;
1695
1696                let (source, source_type) = match wire_ref {
1697                    WireRef::Input(name) => {
1698                        let input_idx = input_to_idx
1699                            .get(name)
1700                            .ok_or_else(|| AssemblyError::UnknownWire(name.clone()))?;
1701                        let source_type = self.input_defs[*input_idx].port_type;
1702                        (WireSource::Input(*input_idx), source_type)
1703                    }
1704                    WireRef::Node(name, out_port) => {
1705                        let src_idx = all_name_to_idx
1706                            .get(name)
1707                            .ok_or_else(|| AssemblyError::UnknownWire(name.clone()))?;
1708                        let src_type = all_nodes[*src_idx].node.meta().outs[*out_port].typ;
1709                        (WireSource::NodeOutput(*src_idx, *out_port), src_type)
1710                    }
1711                };
1712
1713                // A port that takes the wire as it is gets no
1714                // adapter and no check: converting the value would
1715                // change what the node reads. The port says so
1716                // itself (`Port::accepts_any_type`) — this used to be
1717                // decided from a list of thirteen node names, which
1718                // disabled the check on every port of those nodes,
1719                // `pick`'s `Bool` selectors included.
1720                //
1721                // A `Dyn` input feeding a typed port reads through a
1722                // converter to that type (input_variance.md §5), placed
1723                // once per input and type and shared by every reader.
1724                if source_type == PortType::Dyn
1725                    && !port.accepts_any_type
1726                    && expected_type != PortType::Dyn
1727                {
1728                    let WireSource::Input(input_idx) = source else {
1729                        unreachable!("only an input slot is typed `Dyn`")
1730                    };
1731                    let conv_idx = match converters.get(&(input_idx, expected_type)) {
1732                        Some(&idx) => idx,
1733                        None => {
1734                            let converter = crate::convert::InputConverter::new(
1735                                &self.input_defs[input_idx].name,
1736                                expected_type,
1737                            );
1738                            let conv_name = converter.meta().name.clone();
1739                            let idx = all_nodes.len();
1740                            all_name_to_idx.insert(conv_name.clone(), idx);
1741                            while resolved_wiring.len() <= idx {
1742                                resolved_wiring.push(Vec::new());
1743                            }
1744                            resolved_wiring[idx] = vec![source.clone()];
1745                            all_nodes.push(PendingNode {
1746                                name: conv_name,
1747                                node: Box::new(converter),
1748                                inputs: vec![],
1749                            });
1750                            converters.insert((input_idx, expected_type), idx);
1751                            idx
1752                        }
1753                    };
1754                    node_wiring.push(WireSource::NodeOutput(conv_idx, 0));
1755                } else if port.accepts_any_type || source_type == expected_type {
1756                    node_wiring.push(source);
1757                } else if let Some(adapter) = auto_adapter(source_type, expected_type) {
1758                    if strict {
1759                        return Err(AssemblyError::Other(format!(
1760                            "strict mode: implicit type coercion {source_type} → {expected_type} \
1761                             into '{}'. Use an explicit conversion function (e.g., to_f64, \
1762                             to_i64, f64_to_u64).",
1763                            all_nodes[node_idx].name
1764                        )));
1765                    }
1766                    let adapter_name = format!("__adapt_{adapter_count}");
1767                    adapter_count += 1;
1768                    let adapter_idx = all_nodes.len();
1769
1770                    if let Some(ref mut log) = log {
1771                        let from_name = match wire_ref {
1772                            WireRef::Input(n) => n.clone(),
1773                            WireRef::Node(n, _) => n.clone(),
1774                        };
1775                        let to_name = all_nodes[node_idx].name.clone();
1776                        log.push(if is_lossless_widening(source_type, expected_type) {
1777                            crate::dsl::events::CompileEvent::TypeWidening {
1778                                from: source_type.to_keyword(),
1779                                to: expected_type.to_keyword(),
1780                                context: format!("{from_name} → {to_name}"),
1781                            }
1782                        } else {
1783                            crate::dsl::events::CompileEvent::TypeAdapterInserted {
1784                                from_node: from_name,
1785                                to_node: to_name,
1786                                adapter: format!("{source_type:?}→{expected_type:?}"),
1787                            }
1788                        });
1789                    }
1790
1791                    all_name_to_idx.insert(adapter_name.clone(), adapter_idx);
1792
1793                    let adapter_wiring = vec![source];
1794                    while resolved_wiring.len() <= adapter_idx {
1795                        resolved_wiring.push(Vec::new());
1796                    }
1797                    resolved_wiring[adapter_idx] = adapter_wiring;
1798
1799                    all_nodes.push(PendingNode {
1800                        name: adapter_name,
1801                        node: adapter,
1802                        inputs: vec![],
1803                    });
1804
1805                    node_wiring.push(WireSource::NodeOutput(adapter_idx, 0));
1806                } else {
1807                    let from_name = match wire_ref {
1808                        WireRef::Input(n) => n.clone(),
1809                        WireRef::Node(n, _) => n.clone(),
1810                    };
1811                    return Err(AssemblyError::TypeMismatch {
1812                        from_node: from_name,
1813                        from_port: match wire_ref {
1814                            WireRef::Input(_) => 0,
1815                            WireRef::Node(_, p) => *p,
1816                        },
1817                        from_type: source_type,
1818                        to_node: all_nodes[node_idx].name.clone(),
1819                        to_port: port_idx,
1820                        to_type: expected_type,
1821                    });
1822                }
1823
1824                // === Strict-wire assertion insertion (SRD 15) ===
1825                //
1826                // After a wire is resolved (and any type adapter
1827                // inserted), look at the sink port's declared
1828                // `constraint`. If strict_values is on, we either
1829                // prove the source already satisfies it (skip) or
1830                // splice an `AssertValue` node in front of the
1831                // sink. The skip cases mirror the four bullets in
1832                // SRD 15 §"Strict Wire Mode": static type match is
1833                // already handled by the adapter pass above; here
1834                // we cover constant sources and upstream-assertion
1835                // chains for value constraints.
1836                let sink_port = &all_nodes[node_idx].node.meta().wire_inputs()[port_idx];
1837                if let Some(constraint) = sink_port.constraint {
1838                    let last_source = node_wiring.last().expect("wire just pushed").clone();
1839                    if strict_values
1840                        && !value_constraint_proven(&all_nodes, &last_source, &constraint)
1841                    {
1842                        let assert_name = format!("__assert_v_{assertion_count}");
1843                        assertion_count += 1;
1844                        let assert_idx = all_nodes.len();
1845
1846                        if let Some(ref mut log) = log {
1847                            let from_name = match wire_ref {
1848                                WireRef::Input(n) => n.clone(),
1849                                WireRef::Node(n, _) => n.clone(),
1850                            };
1851                            log.push(crate::dsl::events::CompileEvent::AssertionInserted {
1852                                from_node: from_name,
1853                                to_node: all_nodes[node_idx].name.clone(),
1854                                kind: format!("{:?} value-assert {:?}", expected_type, constraint),
1855                            });
1856                        }
1857
1858                        all_name_to_idx.insert(assert_name.clone(), assert_idx);
1859                        let assert_wiring = vec![last_source];
1860                        while resolved_wiring.len() <= assert_idx {
1861                            resolved_wiring.push(Vec::new());
1862                        }
1863                        resolved_wiring[assert_idx] = assert_wiring;
1864
1865                        all_nodes.push(PendingNode {
1866                            name: assert_name,
1867                            node: crate::library::assertions::assert_value_node(
1868                                expected_type,
1869                                constraint,
1870                            ),
1871                            inputs: vec![],
1872                        });
1873
1874                        // Replace the just-pushed source with the
1875                        // assertion's output.
1876                        *node_wiring.last_mut().unwrap() = WireSource::NodeOutput(assert_idx, 0);
1877                    } else if let Some(ref mut log) = log {
1878                        let from_name = match wire_ref {
1879                            WireRef::Input(n) => n.clone(),
1880                            WireRef::Node(n, _) => n.clone(),
1881                        };
1882                        log.push(crate::dsl::events::CompileEvent::AssertionSkipped {
1883                            from_node: from_name,
1884                            to_node: all_nodes[node_idx].name.clone(),
1885                            reason: assertion_skip_reason(
1886                                strict_values,
1887                                &all_nodes,
1888                                &last_source,
1889                                &constraint,
1890                            ),
1891                        });
1892                    }
1893                } else if strict_types && source_type != expected_type {
1894                    // Type mismatch was already adapted above; the
1895                    // post-adapter wire is statically the right
1896                    // type. No assertion needed. Tracking the skip
1897                    // here is forward-compatible — once dynamic
1898                    // type cases (JSON nav, Ext unwraps) appear,
1899                    // this is where the AssertType insertion would
1900                    // hook in.
1901                }
1902            }
1903
1904            while resolved_wiring.len() <= node_idx {
1905                resolved_wiring.push(Vec::new());
1906            }
1907            resolved_wiring[node_idx] = node_wiring;
1908        }
1909
1910        while resolved_wiring.len() < all_nodes.len() {
1911            resolved_wiring.push(Vec::new());
1912        }
1913
1914        // --- Node fusion optimization ---
1915        //
1916        // Recognize fusible subgraph patterns and replace them with
1917        // semantically equivalent fused nodes. See SRD 36.
1918        {
1919            let rules = crate::compile::fusion::default_rules();
1920            if !rules.is_empty() {
1921                // Collect node indices that are directly referenced by outputs.
1922                // These nodes must not be consumed as interior nodes by fusion.
1923                let mut output_nodes: Vec<usize> = Vec::new();
1924                for wire_ref in self.outputs.values() {
1925                    if let WireRef::Node(node_name, _) = wire_ref
1926                        && let Some(&idx) = all_name_to_idx.get(node_name)
1927                    {
1928                        output_nodes.push(idx);
1929                    }
1930                }
1931
1932                // Convert to Option<Box<dyn PolydatNode>> for the fusion pass.
1933                let mut opt_nodes: Vec<Option<Box<dyn PolydatNode>>> =
1934                    all_nodes.into_iter().map(|pn| Some(pn.node)).collect();
1935
1936                let fused_count = crate::compile::fusion::apply_fusions(
1937                    &mut opt_nodes,
1938                    &mut resolved_wiring,
1939                    &mut all_name_to_idx,
1940                    &rules,
1941                    &output_nodes,
1942                );
1943                if fused_count > 0
1944                    && let Some(ref mut log) = log
1945                {
1946                    log.push(crate::dsl::events::CompileEvent::FusionApplied {
1947                        pattern: "subgraph".into(),
1948                        nodes_replaced: fused_count,
1949                    });
1950                }
1951
1952                // Convert back, rebuilding PendingNode wrappers.
1953                // Fused-away nodes (None) get placeholder names.
1954                all_nodes = opt_nodes
1955                    .into_iter()
1956                    .enumerate()
1957                    .map(|(i, opt)| PendingNode {
1958                        name: all_name_to_idx
1959                            .iter()
1960                            .find(|&(_, &idx)| idx == i)
1961                            .map(|(n, _)| n.clone())
1962                            .unwrap_or_else(|| format!("__removed_{i}")),
1963                        node: opt.unwrap_or_else(|| {
1964                            Box::new(crate::library::identity::Identity::new(
1965                                crate::ast::PortType::U64,
1966                            ))
1967                        }),
1968                        inputs: vec![], // wiring is in resolved_wiring
1969                    })
1970                    .collect();
1971            }
1972        }
1973
1974        // --- Dead code elimination ---
1975        //
1976        // Trace backward from output nodes to find all reachable nodes.
1977        // Only reachable nodes participate in the topological sort and
1978        // end up in the final kernel. This prunes unused binding chains
1979        // when the caller requests a subset of outputs.
1980        let node_count = all_nodes.len();
1981        let mut reachable = vec![false; node_count];
1982        {
1983            let mut worklist: Vec<usize> = Vec::new();
1984            // Seed with output nodes
1985            for wire_ref in self.outputs.values() {
1986                if let WireRef::Node(node_name, _) = wire_ref
1987                    && let Some(&idx) = all_name_to_idx.get(node_name)
1988                {
1989                    worklist.push(idx);
1990                }
1991            }
1992            // Side-effecting nodes are pinned alive regardless
1993            // of reachability from a declared output. `log_info`
1994            // and friends emit one audit-log line per eval as a
1995            // deliberate side effect — DCE-pruning them would
1996            // silently drop diagnostic logging the operator
1997            // explicitly asked for. The set is closed and
1998            // matched by node-meta name so the marker survives
1999            // any wiring shape (passthrough, captured-but-unused,
2000            // synthesised wrapper, etc.).
2001            for (idx, pn) in all_nodes.iter().enumerate() {
2002                if matches!(
2003                    pn.node.meta().name.as_str(),
2004                    "log_debug" | "log_info" | "log_warn" | "log_error"
2005                ) {
2006                    worklist.push(idx);
2007                }
2008            }
2009            // Walk backward through wiring
2010            while let Some(idx) = worklist.pop() {
2011                if reachable[idx] {
2012                    continue;
2013                }
2014                reachable[idx] = true;
2015                for source in &resolved_wiring[idx] {
2016                    if let WireSource::NodeOutput(upstream, _) = source
2017                        && !reachable[*upstream]
2018                    {
2019                        worklist.push(*upstream);
2020                    }
2021                }
2022            }
2023        }
2024        let live_count = reachable.iter().filter(|&&r| r).count();
2025
2026        // Every converter that survived pruning is reported at the level
2027        // the host asked for (input_variance.md §4), in the order it was
2028        // placed; an input the setting opened that nothing reads is
2029        // reported once too, so no opened input goes unseen.
2030        if let Some(log) = log.as_deref_mut() {
2031            let mut placed: Vec<(usize, usize, PortType)> = converters
2032                .iter()
2033                .filter(|&(_, &idx)| reachable[idx])
2034                .map(|(&(input, to), &idx)| (idx, input, to))
2035                .collect();
2036            placed.sort_unstable_by_key(|&(idx, _, _)| idx);
2037            for &(idx, input, to) in &placed {
2038                let def = &self.input_defs[input];
2039                let (origin, level) = match (def.converts_to, variance_level) {
2040                    (Some(_), Some(level)) => ("inferred", level),
2041                    _ => ("declared dyn", crate::dsl::events::EventLevel::Info),
2042                };
2043                log.push(crate::dsl::events::CompileEvent::InputConverterInserted {
2044                    input: def.name.clone(),
2045                    to: to.to_keyword().to_string(),
2046                    node: all_nodes[idx].name.clone(),
2047                    origin: origin.to_string(),
2048                    level,
2049                });
2050            }
2051            if let Some(level) = variance_level {
2052                for &input in &open {
2053                    if placed.iter().any(|&(_, i, _)| i == input) {
2054                        continue;
2055                    }
2056                    let def = &self.input_defs[input];
2057                    log.push(crate::dsl::events::CompileEvent::InputConverterInserted {
2058                        input: def.name.clone(),
2059                        to: def
2060                            .converts_to
2061                            .unwrap_or(def.port_type)
2062                            .to_keyword()
2063                            .to_string(),
2064                        node: "(none: nothing reads it)".to_string(),
2065                        origin: "inferred".to_string(),
2066                        level,
2067                    });
2068                }
2069            }
2070        }
2071
2072        // Topological sort (Kahn's algorithm) over reachable nodes only
2073        let mut in_degree = vec![0usize; node_count];
2074        let mut dependents: Vec<Vec<usize>> = vec![Vec::new(); node_count];
2075
2076        for (node_idx, wiring) in resolved_wiring.iter().enumerate() {
2077            if !reachable[node_idx] {
2078                continue;
2079            }
2080            for source in wiring {
2081                if let WireSource::NodeOutput(upstream, _) = source {
2082                    in_degree[node_idx] += 1;
2083                    dependents[*upstream].push(node_idx);
2084                }
2085            }
2086        }
2087
2088        let mut queue: Vec<usize> = (0..node_count)
2089            .filter(|i| reachable[*i] && in_degree[*i] == 0)
2090            .collect();
2091        let mut sorted_order: Vec<usize> = Vec::with_capacity(live_count);
2092
2093        while let Some(idx) = queue.pop() {
2094            sorted_order.push(idx);
2095            for &dep in &dependents[idx] {
2096                in_degree[dep] -= 1;
2097                if in_degree[dep] == 0 {
2098                    queue.push(dep);
2099                }
2100            }
2101        }
2102
2103        if sorted_order.len() != live_count {
2104            return Err(AssemblyError::CycleDetected);
2105        }
2106
2107        let mut old_to_new = vec![0usize; node_count];
2108        for (new_idx, &old_idx) in sorted_order.iter().enumerate() {
2109            old_to_new[old_idx] = new_idx;
2110        }
2111
2112        let mut sorted_nodes: Vec<Option<Box<dyn PolydatNode>>> =
2113            all_nodes.into_iter().map(|pn| Some(pn.node)).collect();
2114
2115        let final_nodes: Vec<Box<dyn PolydatNode>> = sorted_order
2116            .iter()
2117            .map(|&old_idx| sorted_nodes[old_idx].take().unwrap())
2118            .collect();
2119
2120        let final_wiring: Vec<Vec<WireSource>> = sorted_order
2121            .iter()
2122            .map(|&old_idx| {
2123                resolved_wiring[old_idx]
2124                    .iter()
2125                    .map(|source| match source {
2126                        WireSource::Input(c) => WireSource::Input(*c),
2127                        WireSource::NodeOutput(old_up, port) => {
2128                            WireSource::NodeOutput(old_to_new[*old_up], *port)
2129                        }
2130                    })
2131                    .collect()
2132            })
2133            .collect();
2134
2135        let mut final_output_map: HashMap<String, (usize, usize)> = HashMap::new();
2136        for (name, wire_ref) in &self.outputs {
2137            match wire_ref {
2138                WireRef::Input(coord_name) => {
2139                    return Err(AssemblyError::UnknownWire(format!(
2140                        "output '{name}' references coordinate '{coord_name}' directly; \
2141                         wire through a node instead"
2142                    )));
2143                }
2144                WireRef::Node(node_name, port) => {
2145                    let old_idx = all_name_to_idx
2146                        .get(node_name)
2147                        .ok_or_else(|| AssemblyError::UnknownWire(node_name.clone()))?;
2148                    final_output_map.insert(name.clone(), (old_to_new[*old_idx], *port));
2149                }
2150            }
2151        }
2152
2153        // C6b — structural type-round-trip lint (see
2154        // `compile::roundtrip_lint`): a value modulated `T → Y → … → T`
2155        // through pure conversion/formatting machinery violates the
2156        // native-types-stay-native principle. Warning by default; a
2157        // hard error under strict-values mode, matching the SRD 15
2158        // strict-wire constraint discipline.
2159        for f in crate::compile::roundtrip_lint::lint_type_round_trips(
2160            &final_nodes,
2161            &final_wiring,
2162            &self.input_defs,
2163        ) {
2164            if strict_values {
2165                return Err(AssemblyError::Other(f.message()));
2166            }
2167            // Through the audit log, which the host routes; a library
2168            // does not write to the process's stderr on its own.
2169            crate::library::support::audit::warn(&f.message());
2170            if let Some(ref mut log) = log {
2171                log.push(crate::dsl::events::CompileEvent::Warning {
2172                    message: f.message(),
2173                });
2174            }
2175        }
2176
2177        if let Some(log) = log {
2178            let resolved_view = ResolvedDag {
2179                nodes: final_nodes,
2180                wiring: final_wiring,
2181                input_defs: self.input_defs,
2182                coord_count: self.coord_count,
2183                output_map: final_output_map,
2184                output_order: self.output_order,
2185                source: self.source,
2186                context: self.context,
2187                output_modifiers: self.output_modifiers,
2188                const_outputs: self.const_outputs,
2189                const_inits: self.const_inits,
2190                cursor_schemas: self.cursor_schemas,
2191                ledger: self.ledger,
2192            };
2193            Self::log_forms(&resolved_view, log);
2194            return Ok(resolved_view);
2195        }
2196        Ok(ResolvedDag {
2197            nodes: final_nodes,
2198            wiring: final_wiring,
2199            input_defs: self.input_defs,
2200            coord_count: self.coord_count,
2201            output_map: final_output_map,
2202            output_order: self.output_order,
2203            source: self.source,
2204            context: self.context,
2205            output_modifiers: self.output_modifiers,
2206            const_outputs: self.const_outputs,
2207            const_inits: self.const_inits,
2208            cursor_schemas: self.cursor_schemas,
2209            ledger: self.ledger,
2210        })
2211    }
2212}
2213
2214/// Decide whether the source feeding `wire_source` already
2215/// guarantees the sink's value `constraint` at compile time.
2216/// Returns `true` if the assertion can be safely skipped.
2217///
2218/// Today we recognise two skip cases (SRD 15 §"Strict Wire Mode"):
2219///
2220/// 1. **Constant source.** The source node has no wire inputs and
2221///    its name matches the convention used by `fixed::ConstU64`
2222///    et al. Const sources have already been validated against
2223///    their `ParamSpec.constraint` at the factory layer, so any
2224///    further runtime check would be redundant.
2225/// 2. **Upstream assertion.** The source is itself an
2226///    `AssertValue` node (its name starts with `__assert_v_`),
2227///    which already enforces the same or stronger contract.
2228fn value_constraint_proven(
2229    all_nodes: &[PendingNode],
2230    src: &WireSource,
2231    _constraint: &crate::dsl::const_constraints::ConstConstraint,
2232) -> bool {
2233    match src {
2234        WireSource::Input(_) => false,
2235        WireSource::NodeOutput(idx, _) => {
2236            let meta = all_nodes[*idx].node.meta();
2237            // Const-source heuristic: a node with no wire inputs
2238            // is a constant. Today's `ConstU64` / `ConstF64` /
2239            // `ConstBool` (in `nodes::fixed`) and the synthesised
2240            // `ConstNode` from compile-time folding both qualify.
2241            let no_wire_inputs = meta.wire_inputs().is_empty();
2242            if no_wire_inputs {
2243                return true;
2244            }
2245            // Upstream assertion: skip stacking the same guard.
2246            // Conservative — any `__assert_v_*` upstream counts as
2247            // proof. A fancier analysis would compare constraint
2248            // shapes; for now, idempotency is good enough.
2249            if meta.name.starts_with("__assert_v_") || meta.name.starts_with("assert_") {
2250                return true;
2251            }
2252            false
2253        }
2254    }
2255}
2256
2257/// Format the reason a strict-wire assertion was skipped, for the
2258/// `AssertionSkipped` advisory event. Mirrors the bullets in SRD 15
2259/// §"Strict Wire Mode" so the log is grep-able.
2260fn assertion_skip_reason(
2261    strict_values: bool,
2262    all_nodes: &[PendingNode],
2263    src: &WireSource,
2264    _constraint: &crate::dsl::const_constraints::ConstConstraint,
2265) -> String {
2266    if !strict_values {
2267        return "strict_values not enabled".into();
2268    }
2269    match src {
2270        WireSource::Input(_) => "raw input wire".into(),
2271        WireSource::NodeOutput(idx, _) => {
2272            let meta = all_nodes[*idx].node.meta();
2273            if meta.wire_inputs().is_empty() {
2274                "constant source already validated".into()
2275            } else if meta.name.starts_with("__assert_v_") || meta.name.starts_with("assert_") {
2276                "upstream assertion".into()
2277            } else {
2278                "no skip rule matched".into()
2279            }
2280        }
2281    }
2282}
2283
2284/// The `shared` bindings of a resolved graph, by name: each is an
2285/// extern the compiled kernels bind to a cell (engine parity, step 9).
2286pub(crate) fn shared_outputs_of(resolved: &ResolvedDag) -> Vec<&str> {
2287    let mut shared: Vec<&str> = resolved
2288        .output_modifiers
2289        .iter()
2290        .filter(|(_, m)| **m == crate::dsl::ast::BindingModifier::SHARED)
2291        .map(|(name, _)| name.as_str())
2292        .collect();
2293    shared.sort();
2294    shared
2295}
2296
2297/// The port type of each wire input of a node, from its sources: the
2298/// type a compiled lowering sees (SRD 115 §6).
2299/// Whether the adapter from `from` to `to` is a lossless numeric
2300/// widening, the class the adapter table lists first: reported as a
2301/// `TypeWidening`, where every other adapter is a `TypeAdapterInserted`.
2302fn is_lossless_widening(from: PortType, to: PortType) -> bool {
2303    use PortType as P;
2304    matches!(
2305        (from, to),
2306        (P::U64, P::F64)
2307            | (P::U32, P::U64)
2308            | (P::U32, P::I64)
2309            | (P::U32, P::F64)
2310            | (P::I32, P::I64)
2311            | (P::I32, P::F64)
2312            | (P::I64, P::F64)
2313            | (P::F32, P::F64)
2314    )
2315}
2316
2317pub(crate) fn wire_types_of(resolved: &ResolvedDag, node_idx: usize) -> Vec<PortType> {
2318    resolved.wiring[node_idx]
2319        .iter()
2320        .map(|src| match src {
2321            crate::kernel::WireSource::Input(i) => resolved.input_defs[*i].port_type,
2322            crate::kernel::WireSource::NodeOutput(j, p) => resolved.nodes[*j].meta().outs[*p].typ,
2323        })
2324        .collect()
2325}
2326
2327/// The lossless adapter node from one port type to another, if the
2328/// catalog has one: what the assembler inserts between a wire and a port
2329/// of different types.
2330pub fn auto_adapter(from: PortType, to: PortType) -> Option<Box<dyn PolydatNode>> {
2331    use crate::library::convert::{
2332        BoolToStr, BoolToU64, F32ToF64, F32ToString, I32ToF64, I32ToI64, I32ToString, I64ToF64,
2333        I64ToString, U32ToF64, U32ToI64, U32ToString, U32ToU64,
2334    };
2335    use crate::library::polyfill as P;
2336    use crate::library::polyfill_128 as W;
2337    use crate::library::polyfill_complete as C;
2338    use crate::library::polyfill_narrow as N;
2339    match (from, to) {
2340        // ── Numeric widening (lossless) ─────────────────────────
2341        (PortType::U64, PortType::F64) => Some(Box::new(U64ToF64::new())),
2342        (PortType::U32, PortType::U64) => Some(Box::new(U32ToU64::new())),
2343        (PortType::U32, PortType::I64) => Some(Box::new(U32ToI64::new())),
2344        (PortType::U32, PortType::F64) => Some(Box::new(U32ToF64::new())),
2345        (PortType::I32, PortType::I64) => Some(Box::new(I32ToI64::new())),
2346        (PortType::I32, PortType::F64) => Some(Box::new(I32ToF64::new())),
2347        // Rounds past 2^24 and never fails, which is class A —
2348        // totality, not losslessness. The node existed in `polyfill`
2349        // and the element-wise `VecI32 -> VecF32` below was already
2350        // auto-inserted; only this wiring was missing, so the scalar
2351        // of the same two types fell through to a type mismatch.
2352        (PortType::I32, PortType::F32) => Some(Box::new(P::I32ToF32::new())),
2353        (PortType::I64, PortType::F64) => Some(Box::new(I64ToF64::new())),
2354        (PortType::F32, PortType::F64) => Some(Box::new(F32ToF64::new())),
2355
2356        // ── X → Str (every type renders as a string) ────────────
2357        (PortType::U64, PortType::Str) => Some(Box::new(U64ToString::new())),
2358        (PortType::F64, PortType::Str) => Some(Box::new(F64ToString::new())),
2359        (PortType::Bool, PortType::Str) => Some(Box::new(BoolToStr::new())),
2360        (PortType::Json, PortType::Str) => Some(Box::new(JsonToStr::new())),
2361        (PortType::U32, PortType::Str) => Some(Box::new(U32ToString::new())),
2362        (PortType::I32, PortType::Str) => Some(Box::new(I32ToString::new())),
2363        (PortType::I64, PortType::Str) => Some(Box::new(I64ToString::new())),
2364        (PortType::F32, PortType::Str) => Some(Box::new(F32ToString::new())),
2365
2366        // ── Bool ↔ numeric (always-defined; 1/0 mapping) ────────
2367        (PortType::Bool, PortType::U64) => Some(Box::new(BoolToU64::new())),
2368        (PortType::Bool, PortType::U32) => Some(Box::new(P::BoolToU32::new())),
2369        (PortType::Bool, PortType::I64) => Some(Box::new(P::BoolToI64::new())),
2370        (PortType::Bool, PortType::I32) => Some(Box::new(P::BoolToI32::new())),
2371        (PortType::Bool, PortType::F64) => Some(Box::new(P::BoolToF64::new())),
2372        (PortType::Bool, PortType::F32) => Some(Box::new(P::BoolToF32::new())),
2373        (PortType::U64, PortType::Bool) => {
2374            Some(Box::new(crate::library::convert::U64ToBool::new()))
2375        }
2376        (PortType::U32, PortType::Bool) => Some(Box::new(P::U32ToBool::new())),
2377        (PortType::I64, PortType::Bool) => Some(Box::new(P::I64ToBool::new())),
2378        (PortType::I32, PortType::Bool) => Some(Box::new(P::I32ToBool::new())),
2379        (PortType::F64, PortType::Bool) => Some(Box::new(P::F64ToBool::new())),
2380        (PortType::F32, PortType::Bool) => Some(Box::new(P::F32ToBool::new())),
2381
2382        // ── X → Bytes (little-endian serialize, always-defined) ─
2383        (PortType::U64, PortType::Bytes) => Some(Box::new(P::U64ToBytes::new())),
2384        (PortType::U32, PortType::Bytes) => Some(Box::new(P::U32ToBytes::new())),
2385        (PortType::I64, PortType::Bytes) => Some(Box::new(P::I64ToBytes::new())),
2386        (PortType::I32, PortType::Bytes) => Some(Box::new(P::I32ToBytes::new())),
2387        (PortType::F64, PortType::Bytes) => Some(Box::new(P::F64ToBytes::new())),
2388        (PortType::F32, PortType::Bytes) => Some(Box::new(P::F32ToBytes::new())),
2389        (PortType::Bool, PortType::Bytes) => Some(Box::new(P::BoolToBytes::new())),
2390        (PortType::VecF32, PortType::Bytes) => Some(Box::new(P::VecF32ToBytes::new())),
2391        (PortType::VecI32, PortType::Bytes) => Some(Box::new(P::VecI32ToBytes::new())),
2392
2393        // ── X → Json (integer / bool wraps; F* and VecF32 are
2394        //              boundary-only because non-finite floats
2395        //              aren't representable in JSON) ────────────
2396        (PortType::U64, PortType::Json) => Some(Box::new(P::U64ToJson::new())),
2397        (PortType::U32, PortType::Json) => Some(Box::new(P::U32ToJson::new())),
2398        (PortType::I64, PortType::Json) => Some(Box::new(P::I64ToJson::new())),
2399        (PortType::I32, PortType::Json) => Some(Box::new(P::I32ToJson::new())),
2400        (PortType::Bool, PortType::Json) => Some(Box::new(P::BoolToJson::new())),
2401        (PortType::VecI32, PortType::Json) => Some(Box::new(P::VecI32ToJson::new())),
2402
2403        // ── Vec ↔ Vec (VecI32 → VecF32 is lossless) ─────────────
2404        (PortType::VecI32, PortType::VecF32) => Some(Box::new(P::VecI32ToVecF32::new())),
2405
2406        // ── Narrow cranelift widths (u8/i8/u16/i16/f16) ─────────
2407        // Lossless widenings + Display renders + Bool maps + LE
2408        // byte / JSON wraps, mirroring the u32/i32/f32 rows.
2409        // (type_system_alignment.md §2)
2410        (PortType::U8, PortType::U64) => Some(Box::new(N::U8ToU64::new())),
2411        (PortType::U8, PortType::U32) => Some(Box::new(N::U8ToU32::new())),
2412        (PortType::U8, PortType::U16) => Some(Box::new(N::U8ToU16::new())),
2413        (PortType::U8, PortType::F64) => Some(Box::new(N::U8ToF64::new())),
2414        (PortType::U16, PortType::U64) => Some(Box::new(N::U16ToU64::new())),
2415        (PortType::U16, PortType::U32) => Some(Box::new(N::U16ToU32::new())),
2416        (PortType::U16, PortType::F64) => Some(Box::new(N::U16ToF64::new())),
2417        (PortType::I8, PortType::I64) => Some(Box::new(N::I8ToI64::new())),
2418        (PortType::I8, PortType::I32) => Some(Box::new(N::I8ToI32::new())),
2419        (PortType::I8, PortType::I16) => Some(Box::new(N::I8ToI16::new())),
2420        (PortType::I8, PortType::F64) => Some(Box::new(N::I8ToF64::new())),
2421        (PortType::I16, PortType::I64) => Some(Box::new(N::I16ToI64::new())),
2422        (PortType::I16, PortType::I32) => Some(Box::new(N::I16ToI32::new())),
2423        (PortType::I16, PortType::F64) => Some(Box::new(N::I16ToF64::new())),
2424        (PortType::F16, PortType::F32) => Some(Box::new(N::F16ToF32::new())),
2425        (PortType::F16, PortType::F64) => Some(Box::new(N::F16ToF64::new())),
2426        // Totality fills: unsigned → strictly-larger signed, and
2427        // narrow int → f32 (exact, magnitude ≤ 2^24). All class A.
2428        (PortType::U8, PortType::I16) => Some(Box::new(N::U8ToI16::new())),
2429        (PortType::U8, PortType::I32) => Some(Box::new(N::U8ToI32::new())),
2430        (PortType::U8, PortType::I64) => Some(Box::new(N::U8ToI64::new())),
2431        (PortType::U8, PortType::F32) => Some(Box::new(N::U8ToF32::new())),
2432        (PortType::U16, PortType::I32) => Some(Box::new(N::U16ToI32::new())),
2433        (PortType::U16, PortType::I64) => Some(Box::new(N::U16ToI64::new())),
2434        (PortType::U16, PortType::F32) => Some(Box::new(N::U16ToF32::new())),
2435        (PortType::I8, PortType::F32) => Some(Box::new(N::I8ToF32::new())),
2436        (PortType::I16, PortType::F32) => Some(Box::new(N::I16ToF32::new())),
2437        (PortType::U8, PortType::F16) => Some(Box::new(N::U8ToF16::new())),
2438        (PortType::I8, PortType::F16) => Some(Box::new(N::I8ToF16::new())),
2439        (PortType::U8, PortType::Str) => Some(Box::new(N::U8ToString::new())),
2440        (PortType::U16, PortType::Str) => Some(Box::new(N::U16ToString::new())),
2441        (PortType::I8, PortType::Str) => Some(Box::new(N::I8ToString::new())),
2442        (PortType::I16, PortType::Str) => Some(Box::new(N::I16ToString::new())),
2443        (PortType::F16, PortType::Str) => Some(Box::new(N::F16ToString::new())),
2444        (PortType::Bool, PortType::U8) => Some(Box::new(N::BoolToU8::new())),
2445        (PortType::Bool, PortType::U16) => Some(Box::new(N::BoolToU16::new())),
2446        (PortType::Bool, PortType::I8) => Some(Box::new(N::BoolToI8::new())),
2447        (PortType::Bool, PortType::I16) => Some(Box::new(N::BoolToI16::new())),
2448        (PortType::Bool, PortType::F16) => Some(Box::new(N::BoolToF16::new())),
2449        (PortType::U8, PortType::Bool) => Some(Box::new(N::U8ToBool::new())),
2450        (PortType::U16, PortType::Bool) => Some(Box::new(N::U16ToBool::new())),
2451        (PortType::I8, PortType::Bool) => Some(Box::new(N::I8ToBool::new())),
2452        (PortType::I16, PortType::Bool) => Some(Box::new(N::I16ToBool::new())),
2453        (PortType::F16, PortType::Bool) => Some(Box::new(N::F16ToBool::new())),
2454        (PortType::U8, PortType::Bytes) => Some(Box::new(N::U8ToBytes::new())),
2455        (PortType::U16, PortType::Bytes) => Some(Box::new(N::U16ToBytes::new())),
2456        (PortType::I8, PortType::Bytes) => Some(Box::new(N::I8ToBytes::new())),
2457        (PortType::I16, PortType::Bytes) => Some(Box::new(N::I16ToBytes::new())),
2458        (PortType::F16, PortType::Bytes) => Some(Box::new(N::F16ToBytes::new())),
2459        (PortType::U8, PortType::Json) => Some(Box::new(N::U8ToJson::new())),
2460        (PortType::U16, PortType::Json) => Some(Box::new(N::U16ToJson::new())),
2461        (PortType::I8, PortType::Json) => Some(Box::new(N::I8ToJson::new())),
2462        (PortType::I16, PortType::Json) => Some(Box::new(N::I16ToJson::new())),
2463
2464        // ── 128-bit integers (cranelift I128) ───────────────────
2465        // Widenings from the 64-bit carriers, Display renders,
2466        // LE byte / decimal-string JSON wraps. → f64 mirrors
2467        // u64→f64's class-A treatment (defined for every input).
2468        (PortType::U64, PortType::U128) => Some(Box::new(W::U64ToU128::new())),
2469        (PortType::U64, PortType::I128) => Some(Box::new(W::U64ToI128::new())),
2470        (PortType::I64, PortType::I128) => Some(Box::new(W::I64ToI128::new())),
2471        // Totality fills: every ≤64-bit integer widens losslessly
2472        // into the 128-bit carriers (unsigned → both signednesses,
2473        // signed → i128), `bool` widens to both, and the nonzero
2474        // test `128 → bool` is total. All class A.
2475        (PortType::U8, PortType::U128) => Some(Box::new(W::U8ToU128::new())),
2476        (PortType::U8, PortType::I128) => Some(Box::new(W::U8ToI128::new())),
2477        (PortType::U16, PortType::U128) => Some(Box::new(W::U16ToU128::new())),
2478        (PortType::U16, PortType::I128) => Some(Box::new(W::U16ToI128::new())),
2479        (PortType::U32, PortType::U128) => Some(Box::new(W::U32ToU128::new())),
2480        (PortType::U32, PortType::I128) => Some(Box::new(W::U32ToI128::new())),
2481        (PortType::I8, PortType::I128) => Some(Box::new(W::I8ToI128::new())),
2482        (PortType::I16, PortType::I128) => Some(Box::new(W::I16ToI128::new())),
2483        (PortType::I32, PortType::I128) => Some(Box::new(W::I32ToI128::new())),
2484        (PortType::Bool, PortType::U128) => Some(Box::new(W::BoolToU128::new())),
2485        (PortType::Bool, PortType::I128) => Some(Box::new(W::BoolToI128::new())),
2486        (PortType::U128, PortType::Bool) => Some(Box::new(W::U128ToBool::new())),
2487        (PortType::I128, PortType::Bool) => Some(Box::new(W::I128ToBool::new())),
2488        (PortType::U128, PortType::F64) => Some(Box::new(W::U128ToF64::new())),
2489        (PortType::I128, PortType::F64) => Some(Box::new(W::I128ToF64::new())),
2490        (PortType::U128, PortType::Str) => Some(Box::new(W::U128ToString::new())),
2491        (PortType::I128, PortType::Str) => Some(Box::new(W::I128ToString::new())),
2492        (PortType::U128, PortType::Bytes) => Some(Box::new(W::U128ToBytes::new())),
2493        (PortType::I128, PortType::Bytes) => Some(Box::new(W::I128ToBytes::new())),
2494        (PortType::U128, PortType::Json) => Some(Box::new(W::U128ToJson::new())),
2495        (PortType::I128, PortType::Json) => Some(Box::new(W::I128ToJson::new())),
2496
2497        // ── Register views (free bitcasts) ──────────────────────
2498        // Any reg→reg pair heals with a zero-cost retag — the
2499        // materialized "views are free bitcasts" rule
2500        // (type_system_alignment.md §3).
2501        (from, to) if crate::library::register_view::is_reg_port(from) => {
2502            crate::library::register_view::reg_view(to)
2503        }
2504
2505        // ── Vector lane completion — class A (total) ────────────
2506        // Lossless inter-lane widenings, `→ Bytes` serialise, and
2507        // integer-lane `→ Json`/`→ Str`. See library/polyfill_complete.rs.
2508        (PortType::VecI8, PortType::VecI16) => Some(Box::new(C::VecI8ToVecI16::new())),
2509        (PortType::VecI8, PortType::VecI32) => Some(Box::new(C::VecI8ToVecI32::new())),
2510        (PortType::VecI8, PortType::VecI64) => Some(Box::new(C::VecI8ToVecI64::new())),
2511        (PortType::VecI8, PortType::VecF16) => Some(Box::new(C::VecI8ToVecF16::new())),
2512        (PortType::VecI8, PortType::VecF32) => Some(Box::new(C::VecI8ToVecF32::new())),
2513        (PortType::VecI8, PortType::VecF64) => Some(Box::new(C::VecI8ToVecF64::new())),
2514        (PortType::VecI16, PortType::VecI32) => Some(Box::new(C::VecI16ToVecI32::new())),
2515        (PortType::VecI16, PortType::VecI64) => Some(Box::new(C::VecI16ToVecI64::new())),
2516        (PortType::VecI16, PortType::VecF32) => Some(Box::new(C::VecI16ToVecF32::new())),
2517        (PortType::VecI16, PortType::VecF64) => Some(Box::new(C::VecI16ToVecF64::new())),
2518        (PortType::VecI32, PortType::VecI64) => Some(Box::new(C::VecI32ToVecI64::new())),
2519        (PortType::VecI32, PortType::VecF64) => Some(Box::new(C::VecI32ToVecF64::new())),
2520        (PortType::VecI64, PortType::VecF64) => Some(Box::new(C::VecI64ToVecF64::new())),
2521        (PortType::VecF16, PortType::VecF32) => Some(Box::new(C::VecF16ToVecF32::new())),
2522        (PortType::VecF16, PortType::VecF64) => Some(Box::new(C::VecF16ToVecF64::new())),
2523        (PortType::VecF32, PortType::VecF64) => Some(Box::new(C::VecF32ToVecF64::new())),
2524        (PortType::VecF64, PortType::Bytes) => Some(Box::new(C::VecF64ToBytes::new())),
2525        (PortType::VecI64, PortType::Bytes) => Some(Box::new(C::VecI64ToBytes::new())),
2526        (PortType::VecF16, PortType::Bytes) => Some(Box::new(C::VecF16ToBytes::new())),
2527        (PortType::VecI16, PortType::Bytes) => Some(Box::new(C::VecI16ToBytes::new())),
2528        (PortType::VecI8, PortType::Bytes) => Some(Box::new(C::VecI8ToBytes::new())),
2529        (PortType::VecI64, PortType::Json) => Some(Box::new(C::VecI64ToJson::new())),
2530        (PortType::VecI16, PortType::Json) => Some(Box::new(C::VecI16ToJson::new())),
2531        (PortType::VecI8, PortType::Json) => Some(Box::new(C::VecI8ToJson::new())),
2532        (PortType::VecI32, PortType::Str) => Some(Box::new(P::VecI32ToStr::new())),
2533        (PortType::VecI64, PortType::Str) => Some(Box::new(C::VecI64ToStr::new())),
2534        (PortType::VecI16, PortType::Str) => Some(Box::new(C::VecI16ToStr::new())),
2535        (PortType::VecI8, PortType::Str) => Some(Box::new(C::VecI8ToStr::new())),
2536
2537        _ => None,
2538    }
2539}
2540
2541/// Boundary adapter catalog. Consulted by
2542/// `adapt_boundary_value` when a host-injected scope value
2543/// crosses into a typed slot. Strictly a superset of
2544/// [`auto_adapter`]: every intra-graph adapter is also a
2545/// boundary adapter, plus all the lossy / parseable / shape-
2546/// checking adapters that can panic on input the assembler
2547/// can't statically verify.
2548///
2549/// Boundary-only adapters fall into four classes:
2550///
2551/// - **Numeric narrowings** — `U64→{U32, I64, I32, F32}`,
2552///   `F64→{U64, U32, I64, I32, F32}`, etc. Range-checked,
2553///   panic on out-of-range.
2554/// - **Str → X parsers** — workload-param flow (YAML string
2555///   interpolations, comma-split iter-values). Panic on
2556///   unparseable input.
2557/// - **Bytes → X parsers** — wrong-length panics. Numeric
2558///   reads expect exactly sizeof(N) bytes; Vec reads expect
2559///   a multiple of sizeof(element).
2560/// - **Json → X extractors** — shape mismatch panics
2561///   (`Json::Array` expected for Vec; `Json::Number` for
2562///   numerics; etc.).
2563///
2564/// Plus a small set of "almost-auto" adapters that the
2565/// assembler can't promote because they panic on non-finite
2566/// floats: `F64→Json`, `F32→Json`, `VecF32→Json`,
2567/// `VecF32→Str`.
2568///
2569/// See `polydat/docs/design/type_system.md`.
2570pub fn boundary_adapter(from: PortType, to: PortType) -> Option<Box<dyn PolydatNode>> {
2571    if let Some(adapter) = auto_adapter(from, to) {
2572        return Some(adapter);
2573    }
2574    use crate::library::convert::{StrToBool, StrToF64, StrToU64};
2575    use crate::library::polyfill as P;
2576    use crate::library::polyfill_128 as W;
2577    use crate::library::polyfill_complete as C;
2578    use crate::library::polyfill_narrow as N;
2579    match (from, to) {
2580        // ── Numeric narrowings + non-widening casts ─────────────
2581        (PortType::U64, PortType::U32) => Some(Box::new(P::U64ToU32::new())),
2582        (PortType::U64, PortType::I64) => Some(Box::new(P::U64ToI64::new())),
2583        (PortType::U64, PortType::I32) => Some(Box::new(P::U64ToI32::new())),
2584        (PortType::U64, PortType::F32) => Some(Box::new(P::U64ToF32::new())),
2585        (PortType::U32, PortType::I32) => Some(Box::new(P::U32ToI32::new())),
2586        (PortType::U32, PortType::F32) => Some(Box::new(P::U32ToF32::new())),
2587        (PortType::I64, PortType::U64) => Some(Box::new(P::I64ToU64::new())),
2588        (PortType::I64, PortType::U32) => Some(Box::new(P::I64ToU32::new())),
2589        (PortType::I64, PortType::I32) => Some(Box::new(P::I64ToI32::new())),
2590        (PortType::I64, PortType::F32) => Some(Box::new(P::I64ToF32::new())),
2591        (PortType::I32, PortType::U64) => Some(Box::new(P::I32ToU64::new())),
2592        (PortType::I32, PortType::U32) => Some(Box::new(P::I32ToU32::new())),
2593        (PortType::I32, PortType::F32) => Some(Box::new(P::I32ToF32::new())),
2594        (PortType::F64, PortType::U64) => Some(Box::new(P::F64ToU64Checked::new())),
2595        (PortType::F64, PortType::U32) => Some(Box::new(P::F64ToU32::new())),
2596        (PortType::F64, PortType::I64) => Some(Box::new(P::F64ToI64::new())),
2597        (PortType::F64, PortType::I32) => Some(Box::new(P::F64ToI32::new())),
2598        (PortType::F64, PortType::F32) => Some(Box::new(P::F64ToF32::new())),
2599        (PortType::F32, PortType::U64) => Some(Box::new(P::F32ToU64::new())),
2600        (PortType::F32, PortType::U32) => Some(Box::new(P::F32ToU32::new())),
2601        (PortType::F32, PortType::I64) => Some(Box::new(P::F32ToI64::new())),
2602        (PortType::F32, PortType::I32) => Some(Box::new(P::F32ToI32::new())),
2603
2604        // ── Str → X parsers (boundary-only: panic on unparseable)
2605        (PortType::Str, PortType::Bool) => Some(Box::new(StrToBool::new())),
2606        (PortType::Str, PortType::U64) => Some(Box::new(StrToU64::new())),
2607        (PortType::Str, PortType::F64) => Some(Box::new(StrToF64::new())),
2608        (PortType::Str, PortType::U32) => Some(Box::new(P::StrToU32::new())),
2609        (PortType::Str, PortType::I64) => Some(Box::new(P::StrToI64::new())),
2610        (PortType::Str, PortType::I32) => Some(Box::new(P::StrToI32::new())),
2611        (PortType::Str, PortType::F32) => Some(Box::new(P::StrToF32::new())),
2612        (PortType::Str, PortType::Bytes) => Some(Box::new(P::StrToBytes::new())),
2613        (PortType::Str, PortType::Json) => Some(Box::new(P::StrToJson::new())),
2614        (PortType::Str, PortType::VecF32) => Some(Box::new(P::StrToVecF32::new())),
2615        (PortType::Str, PortType::VecI32) => Some(Box::new(P::StrToVecI32::new())),
2616
2617        // ── Bytes → X (length-checked, little-endian) ───────────
2618        (PortType::Bytes, PortType::U64) => Some(Box::new(P::BytesToU64::new())),
2619        (PortType::Bytes, PortType::U32) => Some(Box::new(P::BytesToU32::new())),
2620        (PortType::Bytes, PortType::I64) => Some(Box::new(P::BytesToI64::new())),
2621        (PortType::Bytes, PortType::I32) => Some(Box::new(P::BytesToI32::new())),
2622        (PortType::Bytes, PortType::F64) => Some(Box::new(P::BytesToF64::new())),
2623        (PortType::Bytes, PortType::F32) => Some(Box::new(P::BytesToF32::new())),
2624        (PortType::Bytes, PortType::Bool) => Some(Box::new(P::BytesToBool::new())),
2625        (PortType::Bytes, PortType::Str) => Some(Box::new(P::BytesToStr::new())),
2626        (PortType::Bytes, PortType::Json) => Some(Box::new(P::BytesToJson::new())),
2627        (PortType::Bytes, PortType::VecF32) => Some(Box::new(P::BytesToVecF32::new())),
2628        (PortType::Bytes, PortType::VecI32) => Some(Box::new(P::BytesToVecI32::new())),
2629
2630        // ── Json → X (shape-checked) ────────────────────────────
2631        (PortType::Json, PortType::U64) => Some(Box::new(P::JsonToU64::new())),
2632        (PortType::Json, PortType::U32) => Some(Box::new(P::JsonToU32::new())),
2633        (PortType::Json, PortType::I64) => Some(Box::new(P::JsonToI64::new())),
2634        (PortType::Json, PortType::I32) => Some(Box::new(P::JsonToI32::new())),
2635        (PortType::Json, PortType::F64) => Some(Box::new(P::JsonToF64::new())),
2636        (PortType::Json, PortType::F32) => Some(Box::new(P::JsonToF32::new())),
2637        (PortType::Json, PortType::Bool) => Some(Box::new(P::JsonToBool::new())),
2638        (PortType::Json, PortType::Bytes) => Some(Box::new(P::JsonToBytes::new())),
2639        (PortType::Json, PortType::VecF32) => Some(Box::new(P::JsonToVecF32::new())),
2640        (PortType::Json, PortType::VecI32) => Some(Box::new(P::JsonToVecI32::new())),
2641
2642        // ── Almost-auto (panic on non-finite floats) ────────────
2643        (PortType::F64, PortType::Json) => Some(Box::new(P::F64ToJson::new())),
2644        (PortType::F32, PortType::Json) => Some(Box::new(P::F32ToJson::new())),
2645        (PortType::VecF32, PortType::Json) => Some(Box::new(P::VecF32ToJson::new())),
2646        (PortType::VecF32, PortType::Str) => Some(Box::new(P::VecF32ToStr::new())),
2647
2648        // ── Vec ↔ Vec (lossy round) ─────────────────────────────
2649        (PortType::VecF32, PortType::VecI32) => Some(Box::new(P::VecF32ToVecI32::new())),
2650
2651        // ── Narrow cranelift widths (u8/i8/u16/i16/f16) ─────────
2652        // Range-checked narrowings + parsers + shape-checked
2653        // extractors, mirroring the u32/i32/f32 rows.
2654        (PortType::U64, PortType::U8) => Some(Box::new(N::U64ToU8::new())),
2655        (PortType::U32, PortType::U8) => Some(Box::new(N::U32ToU8::new())),
2656        (PortType::U16, PortType::U8) => Some(Box::new(N::U16ToU8::new())),
2657        (PortType::I64, PortType::U8) => Some(Box::new(N::I64ToU8::new())),
2658        (PortType::F64, PortType::U8) => Some(Box::new(N::F64ToU8::new())),
2659        (PortType::U64, PortType::U16) => Some(Box::new(N::U64ToU16::new())),
2660        (PortType::U32, PortType::U16) => Some(Box::new(N::U32ToU16::new())),
2661        (PortType::I64, PortType::U16) => Some(Box::new(N::I64ToU16::new())),
2662        (PortType::F64, PortType::U16) => Some(Box::new(N::F64ToU16::new())),
2663        (PortType::I64, PortType::I8) => Some(Box::new(N::I64ToI8::new())),
2664        (PortType::I32, PortType::I8) => Some(Box::new(N::I32ToI8::new())),
2665        (PortType::U64, PortType::I8) => Some(Box::new(N::U64ToI8::new())),
2666        (PortType::F64, PortType::I8) => Some(Box::new(N::F64ToI8::new())),
2667        (PortType::I64, PortType::I16) => Some(Box::new(N::I64ToI16::new())),
2668        (PortType::I32, PortType::I16) => Some(Box::new(N::I32ToI16::new())),
2669        (PortType::U64, PortType::I16) => Some(Box::new(N::U64ToI16::new())),
2670        (PortType::F64, PortType::I16) => Some(Box::new(N::F64ToI16::new())),
2671        (PortType::F64, PortType::F16) => Some(Box::new(N::F64ToF16::new())),
2672        (PortType::F32, PortType::F16) => Some(Box::new(N::F32ToF16::new())),
2673        (PortType::U64, PortType::F16) => Some(Box::new(N::U64ToF16::new())),
2674        (PortType::Str, PortType::U8) => Some(Box::new(N::StrToU8::new())),
2675        (PortType::Str, PortType::U16) => Some(Box::new(N::StrToU16::new())),
2676        (PortType::Str, PortType::I8) => Some(Box::new(N::StrToI8::new())),
2677        (PortType::Str, PortType::I16) => Some(Box::new(N::StrToI16::new())),
2678        (PortType::Str, PortType::F16) => Some(Box::new(N::StrToF16::new())),
2679        (PortType::Bytes, PortType::U8) => Some(Box::new(N::BytesToU8::new())),
2680        (PortType::Bytes, PortType::U16) => Some(Box::new(N::BytesToU16::new())),
2681        (PortType::Bytes, PortType::I8) => Some(Box::new(N::BytesToI8::new())),
2682        (PortType::Bytes, PortType::I16) => Some(Box::new(N::BytesToI16::new())),
2683        (PortType::Bytes, PortType::F16) => Some(Box::new(N::BytesToF16::new())),
2684        (PortType::Json, PortType::U8) => Some(Box::new(N::JsonToU8::new())),
2685        (PortType::Json, PortType::U16) => Some(Box::new(N::JsonToU16::new())),
2686        (PortType::Json, PortType::I8) => Some(Box::new(N::JsonToI8::new())),
2687        (PortType::Json, PortType::I16) => Some(Box::new(N::JsonToI16::new())),
2688        (PortType::Json, PortType::F16) => Some(Box::new(N::JsonToF16::new())),
2689        // f16 → Json panics on non-finite (same as f32 → Json).
2690        (PortType::F16, PortType::Json) => Some(Box::new(N::F16ToJson::new())),
2691
2692        // ── 128-bit integers (range-checked / parse / shape) ────
2693        (PortType::U128, PortType::U64) => Some(Box::new(W::U128ToU64::new())),
2694        (PortType::I128, PortType::I64) => Some(Box::new(W::I128ToI64::new())),
2695        (PortType::I64, PortType::U128) => Some(Box::new(W::I64ToU128::new())),
2696        (PortType::U128, PortType::I128) => Some(Box::new(W::U128ToI128::new())),
2697        (PortType::I128, PortType::U128) => Some(Box::new(W::I128ToU128::new())),
2698        (PortType::F64, PortType::U128) => Some(Box::new(W::F64ToU128::new())),
2699        (PortType::F64, PortType::I128) => Some(Box::new(W::F64ToI128::new())),
2700        (PortType::Str, PortType::U128) => Some(Box::new(W::StrToU128::new())),
2701        (PortType::Str, PortType::I128) => Some(Box::new(W::StrToI128::new())),
2702        (PortType::Bytes, PortType::U128) => Some(Box::new(W::BytesToU128::new())),
2703        (PortType::Bytes, PortType::I128) => Some(Box::new(W::BytesToI128::new())),
2704        (PortType::Json, PortType::U128) => Some(Box::new(W::JsonToU128::new())),
2705        (PortType::Json, PortType::I128) => Some(Box::new(W::JsonToI128::new())),
2706
2707        // ── Scalar matrix completion (library/polyfill_complete.rs) ──
2708        // Every remaining scalar→scalar narrowing / cross-sign /
2709        // float→int / int→narrow-float cell, so the 14×14 scalar
2710        // block has no `·`. All class B (range-checked, can panic).
2711        (PortType::U8, PortType::I8) => Some(Box::new(C::U8ToI8::new())),
2712        (PortType::I8, PortType::U8) => Some(Box::new(C::I8ToU8::new())),
2713        (PortType::I8, PortType::U16) => Some(Box::new(C::I8ToU16::new())),
2714        (PortType::I8, PortType::U32) => Some(Box::new(C::I8ToU32::new())),
2715        (PortType::I8, PortType::U64) => Some(Box::new(C::I8ToU64::new())),
2716        (PortType::I8, PortType::U128) => Some(Box::new(C::I8ToU128::new())),
2717        (PortType::U16, PortType::I8) => Some(Box::new(C::U16ToI8::new())),
2718        (PortType::U16, PortType::I16) => Some(Box::new(C::U16ToI16::new())),
2719        (PortType::U16, PortType::F16) => Some(Box::new(C::U16ToF16::new())),
2720        (PortType::I16, PortType::U8) => Some(Box::new(C::I16ToU8::new())),
2721        (PortType::I16, PortType::I8) => Some(Box::new(C::I16ToI8::new())),
2722        (PortType::I16, PortType::U16) => Some(Box::new(C::I16ToU16::new())),
2723        (PortType::I16, PortType::F16) => Some(Box::new(C::I16ToF16::new())),
2724        (PortType::I16, PortType::U32) => Some(Box::new(C::I16ToU32::new())),
2725        (PortType::I16, PortType::U64) => Some(Box::new(C::I16ToU64::new())),
2726        (PortType::I16, PortType::U128) => Some(Box::new(C::I16ToU128::new())),
2727        (PortType::U32, PortType::I8) => Some(Box::new(C::U32ToI8::new())),
2728        (PortType::U32, PortType::I16) => Some(Box::new(C::U32ToI16::new())),
2729        (PortType::U32, PortType::F16) => Some(Box::new(C::U32ToF16::new())),
2730        (PortType::I32, PortType::U8) => Some(Box::new(C::I32ToU8::new())),
2731        (PortType::I32, PortType::U16) => Some(Box::new(C::I32ToU16::new())),
2732        (PortType::I32, PortType::F16) => Some(Box::new(C::I32ToF16::new())),
2733        (PortType::I32, PortType::U128) => Some(Box::new(C::I32ToU128::new())),
2734        (PortType::F16, PortType::U8) => Some(Box::new(C::F16ToU8::new())),
2735        (PortType::F16, PortType::I8) => Some(Box::new(C::F16ToI8::new())),
2736        (PortType::F16, PortType::U16) => Some(Box::new(C::F16ToU16::new())),
2737        (PortType::F16, PortType::I16) => Some(Box::new(C::F16ToI16::new())),
2738        (PortType::F16, PortType::U32) => Some(Box::new(C::F16ToU32::new())),
2739        (PortType::F16, PortType::I32) => Some(Box::new(C::F16ToI32::new())),
2740        (PortType::F16, PortType::U64) => Some(Box::new(C::F16ToU64::new())),
2741        (PortType::F16, PortType::I64) => Some(Box::new(C::F16ToI64::new())),
2742        (PortType::F16, PortType::U128) => Some(Box::new(C::F16ToU128::new())),
2743        (PortType::F16, PortType::I128) => Some(Box::new(C::F16ToI128::new())),
2744        (PortType::F32, PortType::U8) => Some(Box::new(C::F32ToU8::new())),
2745        (PortType::F32, PortType::I8) => Some(Box::new(C::F32ToI8::new())),
2746        (PortType::F32, PortType::U16) => Some(Box::new(C::F32ToU16::new())),
2747        (PortType::F32, PortType::I16) => Some(Box::new(C::F32ToI16::new())),
2748        (PortType::F32, PortType::U128) => Some(Box::new(C::F32ToU128::new())),
2749        (PortType::F32, PortType::I128) => Some(Box::new(C::F32ToI128::new())),
2750        (PortType::I64, PortType::F16) => Some(Box::new(C::I64ToF16::new())),
2751        (PortType::U128, PortType::U8) => Some(Box::new(C::U128ToU8::new())),
2752        (PortType::U128, PortType::I8) => Some(Box::new(C::U128ToI8::new())),
2753        (PortType::U128, PortType::U16) => Some(Box::new(C::U128ToU16::new())),
2754        (PortType::U128, PortType::I16) => Some(Box::new(C::U128ToI16::new())),
2755        (PortType::U128, PortType::F16) => Some(Box::new(C::U128ToF16::new())),
2756        (PortType::U128, PortType::U32) => Some(Box::new(C::U128ToU32::new())),
2757        (PortType::U128, PortType::I32) => Some(Box::new(C::U128ToI32::new())),
2758        (PortType::U128, PortType::F32) => Some(Box::new(C::U128ToF32::new())),
2759        (PortType::U128, PortType::I64) => Some(Box::new(C::U128ToI64::new())),
2760        (PortType::I128, PortType::U8) => Some(Box::new(C::I128ToU8::new())),
2761        (PortType::I128, PortType::I8) => Some(Box::new(C::I128ToI8::new())),
2762        (PortType::I128, PortType::U16) => Some(Box::new(C::I128ToU16::new())),
2763        (PortType::I128, PortType::I16) => Some(Box::new(C::I128ToI16::new())),
2764        (PortType::I128, PortType::F16) => Some(Box::new(C::I128ToF16::new())),
2765        (PortType::I128, PortType::U32) => Some(Box::new(C::I128ToU32::new())),
2766        (PortType::I128, PortType::I32) => Some(Box::new(C::I128ToI32::new())),
2767        (PortType::I128, PortType::F32) => Some(Box::new(C::I128ToF32::new())),
2768        (PortType::I128, PortType::U64) => Some(Box::new(C::I128ToU64::new())),
2769
2770        // ── Vector lane completion — class B (lossy / checked) ──
2771        // Inter-lane narrowing + float→int, Bytes/Json/Str decode &
2772        // parse, float-lane → Json/Str (non-finite panics).
2773        (PortType::VecI16, PortType::VecI8) => Some(Box::new(C::VecI16ToVecI8::new())),
2774        (PortType::VecI16, PortType::VecF16) => Some(Box::new(C::VecI16ToVecF16::new())),
2775        (PortType::VecI32, PortType::VecI8) => Some(Box::new(C::VecI32ToVecI8::new())),
2776        (PortType::VecI32, PortType::VecI16) => Some(Box::new(C::VecI32ToVecI16::new())),
2777        (PortType::VecI32, PortType::VecF16) => Some(Box::new(C::VecI32ToVecF16::new())),
2778        (PortType::VecI64, PortType::VecI8) => Some(Box::new(C::VecI64ToVecI8::new())),
2779        (PortType::VecI64, PortType::VecI16) => Some(Box::new(C::VecI64ToVecI16::new())),
2780        (PortType::VecI64, PortType::VecI32) => Some(Box::new(C::VecI64ToVecI32::new())),
2781        (PortType::VecI64, PortType::VecF16) => Some(Box::new(C::VecI64ToVecF16::new())),
2782        (PortType::VecI64, PortType::VecF32) => Some(Box::new(C::VecI64ToVecF32::new())),
2783        (PortType::VecF16, PortType::VecI8) => Some(Box::new(C::VecF16ToVecI8::new())),
2784        (PortType::VecF16, PortType::VecI16) => Some(Box::new(C::VecF16ToVecI16::new())),
2785        (PortType::VecF16, PortType::VecI32) => Some(Box::new(C::VecF16ToVecI32::new())),
2786        (PortType::VecF16, PortType::VecI64) => Some(Box::new(C::VecF16ToVecI64::new())),
2787        (PortType::VecF32, PortType::VecI8) => Some(Box::new(C::VecF32ToVecI8::new())),
2788        (PortType::VecF32, PortType::VecI16) => Some(Box::new(C::VecF32ToVecI16::new())),
2789        (PortType::VecF32, PortType::VecI64) => Some(Box::new(C::VecF32ToVecI64::new())),
2790        (PortType::VecF32, PortType::VecF16) => Some(Box::new(C::VecF32ToVecF16::new())),
2791        (PortType::VecF64, PortType::VecI8) => Some(Box::new(C::VecF64ToVecI8::new())),
2792        (PortType::VecF64, PortType::VecI16) => Some(Box::new(C::VecF64ToVecI16::new())),
2793        (PortType::VecF64, PortType::VecI32) => Some(Box::new(C::VecF64ToVecI32::new())),
2794        (PortType::VecF64, PortType::VecI64) => Some(Box::new(C::VecF64ToVecI64::new())),
2795        (PortType::VecF64, PortType::VecF16) => Some(Box::new(C::VecF64ToVecF16::new())),
2796        (PortType::VecF64, PortType::VecF32) => Some(Box::new(C::VecF64ToVecF32::new())),
2797        (PortType::Bytes, PortType::VecF64) => Some(Box::new(C::BytesToVecF64::new())),
2798        (PortType::Bytes, PortType::VecI64) => Some(Box::new(C::BytesToVecI64::new())),
2799        (PortType::Bytes, PortType::VecF16) => Some(Box::new(C::BytesToVecF16::new())),
2800        (PortType::Bytes, PortType::VecI16) => Some(Box::new(C::BytesToVecI16::new())),
2801        (PortType::Bytes, PortType::VecI8) => Some(Box::new(C::BytesToVecI8::new())),
2802        (PortType::VecF64, PortType::Json) => Some(Box::new(C::VecF64ToJson::new())),
2803        (PortType::VecF16, PortType::Json) => Some(Box::new(C::VecF16ToJson::new())),
2804        (PortType::Json, PortType::VecF64) => Some(Box::new(C::JsonToVecF64::new())),
2805        (PortType::Json, PortType::VecI64) => Some(Box::new(C::JsonToVecI64::new())),
2806        (PortType::Json, PortType::VecF16) => Some(Box::new(C::JsonToVecF16::new())),
2807        (PortType::Json, PortType::VecI16) => Some(Box::new(C::JsonToVecI16::new())),
2808        (PortType::Json, PortType::VecI8) => Some(Box::new(C::JsonToVecI8::new())),
2809        (PortType::VecF64, PortType::Str) => Some(Box::new(C::VecF64ToStr::new())),
2810        (PortType::VecF16, PortType::Str) => Some(Box::new(C::VecF16ToStr::new())),
2811        (PortType::Str, PortType::VecF64) => Some(Box::new(C::StrToVecF64::new())),
2812        (PortType::Str, PortType::VecI64) => Some(Box::new(C::StrToVecI64::new())),
2813        (PortType::Str, PortType::VecF16) => Some(Box::new(C::StrToVecF16::new())),
2814        (PortType::Str, PortType::VecI16) => Some(Box::new(C::StrToVecI16::new())),
2815        (PortType::Str, PortType::VecI8) => Some(Box::new(C::StrToVecI8::new())),
2816
2817        _ => None,
2818    }
2819}
2820
2821// ── The one constructor (engines.md §3.5) ─────────────────
2822
2823use crate::compile::select::{Engine, KernelError, Provenance};
2824use crate::kernel::Kernel;
2825
2826impl PolydatAssembler {
2827    /// Build a kernel on `engine`: the interpreter, the closure tier,
2828    /// the hybrid kernel, or pure native code, with the provenance mode
2829    /// the engine names. Every engine accepts every program the
2830    /// interpreter accepts, or refuses it with a reason naming the node
2831    /// or construct ([`KernelError::Refused`]). The older constructors
2832    /// (`compile`, `try_compile*`, `compile_hybrid`)
2833    /// remain as aliases of this one for their engine.
2834    pub fn compile_with(self, engine: Engine) -> Result<Box<dyn Kernel>, KernelError> {
2835        self.compile_engine_with_log(engine, None)
2836    }
2837
2838    /// [`Self::compile_with`] on [`Engine::default`]: compiled code, with
2839    /// the JIT where the build has it.
2840    pub fn compile_kernel(self) -> Result<Box<dyn Kernel>, KernelError> {
2841        self.compile_with(Engine::default())
2842    }
2843
2844    /// [`Self::compile_with`] with the compile event log, which
2845    /// receives the assembly events for every engine.
2846    pub fn compile_engine_with_log(
2847        self,
2848        engine: Engine,
2849        log: Option<&mut crate::dsl::events::CompileEventLog>,
2850    ) -> Result<Box<dyn Kernel>, KernelError> {
2851        match engine {
2852            // The one engine with no slot surface, and so the one this
2853            // function builds itself.
2854            Engine::Interpreter(cones) => {
2855                let mut asm = self;
2856                asm.jit_mode = Some(cones);
2857                Ok(Box::new(asm.compile_with_log(log)?))
2858            }
2859            // Every compiled engine is built once, by
2860            // `compile_slots_with_log`, and upcast for the caller who
2861            // asked for the ordinary surface. One builder, two views.
2862            _ => Ok(self.compile_slots_with_log(engine, log)?),
2863        }
2864    }
2865
2866    /// Build on `engine` and keep the slot surface: the same kernel
2867    /// [`Self::compile_with`] builds, typed as
2868    /// [`SlotKernel`](crate::compile::SlotKernel) so a caller can read
2869    /// a buffer slot and evaluate one without boxing a `Value`.
2870    ///
2871    /// For testing, measurement and diagnostics, where the layout is
2872    /// the subject. Normative use is `compile_with`, which returns the
2873    /// same kernel as `Box<dyn Kernel>`; a `Box<dyn SlotKernel>`
2874    /// upcasts to one wherever the ordinary surface will do, so a
2875    /// caller that wants both needs only this call.
2876    ///
2877    /// `Engine::Interpreter` is refused, and cannot be anything else:
2878    /// the interpreter holds typed `Value` buffers and has no slot to
2879    /// name. Ask for a compiled engine, or use `compile_with` and the
2880    /// `Kernel` trait, which every engine answers.
2881    pub fn compile_slots(
2882        self,
2883        engine: Engine,
2884    ) -> Result<Box<dyn crate::compile::SlotKernel>, KernelError> {
2885        self.compile_slots_with_log(engine, None)
2886    }
2887
2888    /// [`Self::compile_slots`] with the compile event log. The kernel is
2889    /// initialized before it is returned: its consts are evaluated.
2890    pub fn compile_slots_with_log(
2891        self,
2892        engine: Engine,
2893        log: Option<&mut crate::dsl::events::CompileEventLog>,
2894    ) -> Result<Box<dyn crate::compile::SlotKernel>, KernelError> {
2895        let template = self.template;
2896        let mut kernel = self.build_slots_with_log(engine, log)?;
2897        if !template {
2898            crate::kernel::Kernel::init(kernel.as_mut())?;
2899        }
2900        Ok(kernel)
2901    }
2902
2903    /// The compiled kernel on `engine`, not yet initialized.
2904    fn build_slots_with_log(
2905        self,
2906        engine: Engine,
2907        mut log: Option<&mut crate::dsl::events::CompileEventLog>,
2908    ) -> Result<Box<dyn crate::compile::SlotKernel>, KernelError> {
2909        let refused = |reason: String| KernelError::Refused { engine, reason };
2910        // A builder names its tier but not the provenance mode it was
2911        // asked for, which the caller is entitled to see back. Only a
2912        // refusal is restamped: a fold failure belongs to the program
2913        // and names no engine at all.
2914        let asked = |e: KernelError| match e {
2915            KernelError::Refused { reason, .. } => KernelError::Refused { engine, reason },
2916            other => other,
2917        };
2918        let strict = self.strict;
2919        match engine {
2920            Engine::Interpreter(_) => Err(refused(
2921                "the interpreter has no slot buffer: its buffers are typed `Value`s, so \
2922                 there is no slot to name. Ask for `closures`, `native` or `pure-native` \
2923                 for the slot surface, or compile with `compile_with` and drive the \
2924                 kernel through the `Kernel` trait, which every engine answers."
2925                    .into(),
2926            )),
2927            Engine::Closures(prov) => {
2928                let resolved = self.resolve_with_log(log.as_deref_mut())?;
2929                if strict {
2930                    Self::refuse_strict(&resolved)?;
2931                }
2932                let folded = log.is_some().then(|| Self::constant_sites(&resolved));
2933                let (node_total, output_total) =
2934                    (resolved.nodes.len(), resolved.output_order.len());
2935                let kernel = Self::closures_from(resolved, prov).map_err(asked)?;
2936                Self::log_folded(kernel.as_ref(), folded, log.as_deref_mut());
2937                Self::log_summary(log, node_total, output_total);
2938                Ok(kernel)
2939            }
2940            // Available in every build. Without the `jit` feature this
2941            // engine's kernel has no native segment in it and every
2942            // step is a closure, which `plan()` reports as it reports
2943            // any other mix; the engine is the kernel architecture, and
2944            // how much of it got native code is the plan. Refusing here
2945            // would take a working tier away from an architecture that
2946            // has no code generator, which is the one place it is most
2947            // worth keeping every engine that can be built.
2948            Engine::Native(prov) => {
2949                {
2950                    let resolved = self.resolve_with_log(log.as_deref_mut())?;
2951                    if strict {
2952                        Self::refuse_strict(&resolved)?;
2953                    }
2954                    let folded = log.is_some().then(|| Self::constant_sites(&resolved));
2955                    let (node_total, output_total) =
2956                        (resolved.nodes.len(), resolved.output_order.len());
2957                    // Push without the cone guard has no native kernel
2958                    // (engines.md §4), so a request for it cannot be
2959                    // realized. Refuse it rather than build push-pull and
2960                    // report a mode the caller did not ask for: a kernel's
2961                    // reported configuration is the one it runs.
2962                    if prov == Provenance::Push {
2963                        return Err(refused(
2964                            "native code has no push-only kernel: push-side invalidation \
2965                             without the cone guard has no native form. Ask for `pushpull` \
2966                             for both, `pull` for the guard alone, or `auto` to let the \
2967                             selector choose; `push` alone is available on the closure tier."
2968                                .into(),
2969                        ));
2970                    }
2971                    let prov = Self::provenance_for(prov, &resolved);
2972                    let kernel = Self::hybrid_from(resolved).map_err(asked)?;
2973                    let kernel: Box<dyn crate::compile::SlotKernel> = match prov {
2974                        Provenance::Raw => Box::new(kernel.into_raw()),
2975                        Provenance::Pull => Box::new(kernel.into_pull()),
2976                        // `provenance_for` resolves `Auto` to `Raw`,
2977                        // `Pull`, or `PushPull`, and `Push` was refused
2978                        // above, so this arm is `PushPull` in practice.
2979                        // It refuses rather than panics if the selector
2980                        // ever gains a mode with no native kernel.
2981                        Provenance::PushPull | Provenance::Auto => Box::new(kernel),
2982                        Provenance::Push => {
2983                            return Err(refused("native code has no push-only kernel".into()));
2984                        }
2985                    };
2986                    Self::log_folded(kernel.as_ref(), folded, log.as_deref_mut());
2987                    Self::log_summary(log, node_total, output_total);
2988                    Ok(kernel)
2989                }
2990            }
2991            Engine::PureNative(prov) => {
2992                #[cfg(feature = "jit")]
2993                {
2994                    let resolved = self.resolve_with_log(log.as_deref_mut())?;
2995                    if strict {
2996                        Self::refuse_strict(&resolved)?;
2997                    }
2998                    let folded = log.is_some().then(|| Self::constant_sites(&resolved));
2999                    let (node_total, output_total) =
3000                        (resolved.nodes.len(), resolved.output_order.len());
3001                    // Only raw and push+pull have a pure kernel. A named
3002                    // mode with none is refused rather than silently
3003                    // answered with another, because a kernel reports
3004                    // the configuration it runs; `Auto` delegated the
3005                    // choice, so the selector's pull resolves to
3006                    // push+pull, whose guard subsumes it.
3007                    let prov = match prov {
3008                        Provenance::Auto => match Self::provenance_for(prov, &resolved) {
3009                            Provenance::Raw => Provenance::Raw,
3010                            _ => Provenance::PushPull,
3011                        },
3012                        named @ (Provenance::Raw | Provenance::PushPull) => named,
3013                        other => {
3014                            return Err(refused(format!(
3015                                "pure native code has no {} kernel: the tier keeps only the \
3016                                 two forms the differential needs. Ask for `raw` or \
3017                                 `pushpull`, or `auto` to let the selector choose; every \
3018                                 mode is available on `native`.",
3019                                format!("{other:?}").to_lowercase(),
3020                            )));
3021                        }
3022                    };
3023                    let kernel: Box<dyn crate::compile::SlotKernel> = match prov {
3024                        Provenance::Raw => Box::new(Self::jit_raw_from(resolved).map_err(asked)?),
3025                        _ => Box::new(Self::jit_push_pull_from(resolved).map_err(asked)?),
3026                    };
3027                    Self::log_folded(kernel.as_ref(), folded, log.as_deref_mut());
3028                    Self::log_summary(log, node_total, output_total);
3029                    Ok(kernel)
3030                }
3031                #[cfg(not(feature = "jit"))]
3032                {
3033                    let _ = (prov, log);
3034                    Err(refused(
3035                        "this build has no native code (the `jit` feature is off)".into(),
3036                    ))
3037                }
3038            }
3039        }
3040    }
3041
3042    /// The nodes the compile-constant fold applies to, as the
3043    /// interpreter's fold selects them: no input reaches the node and it
3044    /// has one output; with the slot and type to read once the kernel is
3045    /// built.
3046    fn constant_sites(resolved: &ResolvedDag) -> Vec<(String, usize, crate::ast::PortType)> {
3047        let classes = PolydatProgram::classify_lifecycle(
3048            &resolved.nodes,
3049            &resolved.wiring,
3050            &resolved.input_defs,
3051            &resolved.output_map,
3052            &resolved.output_modifiers,
3053        );
3054        let layout = slot_layout(resolved);
3055        resolved
3056            .nodes
3057            .iter()
3058            .enumerate()
3059            .filter(|(i, n)| {
3060                classes.lifecycle[*i] == crate::kernel::EvalLifecycle::CompileConst
3061                    && n.meta().outs.len() == 1
3062            })
3063            .map(|(i, n)| {
3064                (
3065                    n.meta().name.clone(),
3066                    layout.port_offsets[i][0],
3067                    n.meta().outs[0].typ,
3068                )
3069            })
3070            .collect()
3071    }
3072
3073    /// Record the constants the build folded, as the interpreter's fold
3074    /// records its own: one event per node, with the value it holds.
3075    fn log_folded(
3076        kernel: &dyn Kernel,
3077        sites: Option<Vec<(String, usize, crate::ast::PortType)>>,
3078        log: Option<&mut crate::dsl::events::CompileEventLog>,
3079    ) {
3080        let (Some(sites), Some(log)) = (sites, log) else {
3081            return;
3082        };
3083        for (node, slot, ty) in sites {
3084            let value = crate::kernel::KernelInternals::slot_value(kernel, slot, ty);
3085            if !matches!(value, crate::ast::Value::None) {
3086                log.push(crate::dsl::events::CompileEvent::ConstantFolded {
3087                    node,
3088                    value: value.to_display_string(),
3089                });
3090            }
3091        }
3092    }
3093
3094    /// The provenance mode a compiled engine builds for `prov`: `Auto`
3095    /// is the selector's choice from the resolved graph's shape
3096    /// ([`select::select_prov_mode`]), on the closure tier and the
3097    /// native engine alike; a named mode is taken as given.
3098    fn provenance_for(prov: Provenance, resolved: &ResolvedDag) -> Provenance {
3099        match prov {
3100            Provenance::Auto => {
3101                let analysis =
3102                    select::analyze_graph(&resolved.nodes, &resolved.wiring, &resolved.output_map);
3103                match select::select_prov_mode(&analysis) {
3104                    ProvMode::Raw => Provenance::Raw,
3105                    ProvMode::Pull => Provenance::Pull,
3106                    ProvMode::PushPull => Provenance::PushPull,
3107                }
3108            }
3109            p => p,
3110        }
3111    }
3112
3113    /// The closure-tier kernel of a resolved graph in one provenance
3114    /// mode, or why the closure tier refuses the graph.
3115    fn closures_from(
3116        resolved: ResolvedDag,
3117        prov: Provenance,
3118    ) -> Result<Box<dyn crate::compile::SlotKernel>, KernelError> {
3119        let prov = Self::provenance_for(prov, &resolved);
3120        let (coord_count, total_slots, steps, output_map, ref_slots, extras) =
3121            Self::build_p2_layout(&resolved).map_err(Self::refused_by_closures)?;
3122        let dependents = || {
3123            slot_layout(&resolved).expand_dependents(
3124                &resolved,
3125                &PolydatProgram::compute_dependents(
3126                    &PolydatProgram::compute_provenance(&resolved.nodes, &resolved.wiring),
3127                    resolved.input_defs.len(),
3128                ),
3129            )
3130        };
3131        Ok(match prov {
3132            Provenance::Raw => Box::new(CompiledKernelRaw::new(
3133                coord_count,
3134                total_slots,
3135                steps,
3136                output_map,
3137                ref_slots,
3138                extras,
3139            )?),
3140            Provenance::Push => Box::new(CompiledKernelPush::new(
3141                coord_count,
3142                total_slots,
3143                steps,
3144                output_map,
3145                dependents(),
3146                ref_slots,
3147                extras,
3148            )?),
3149            Provenance::Pull => Box::new(CompiledKernelPull::new(
3150                coord_count,
3151                total_slots,
3152                steps,
3153                output_map,
3154                &dependents(),
3155                ref_slots,
3156                extras,
3157            )?),
3158            Provenance::PushPull | Provenance::Auto => Box::new(CompiledKernelPushPull::new(
3159                coord_count,
3160                total_slots,
3161                steps,
3162                output_map,
3163                dependents(),
3164                ref_slots,
3165                extras,
3166            )?),
3167        })
3168    }
3169}