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.externs.set_fixed_outputs(fixed_outputs(resolved));
1039        extras.output_types = resolved
1040            .output_map
1041            .iter()
1042            .map(|(name, (n, p))| (name.clone(), resolved.nodes[*n].meta().outs[*p].typ))
1043            .collect();
1044
1045        // The runtime model's lifecycle classification, the one rule the
1046        // interpreter's fold applies, and the provenance the plan is
1047        // derived from.
1048        let classes = PolydatProgram::classify_lifecycle(
1049            &resolved.nodes,
1050            &resolved.wiring,
1051            &resolved.input_defs,
1052            &resolved.output_map,
1053            &resolved.output_modifiers,
1054        );
1055        let inventory = PolydatProgram::compute_node_inventory(&resolved.nodes, &resolved.wiring);
1056        let per_input = PolydatProgram::compute_dependents(
1057            &inventory.input_provenance,
1058            resolved.input_defs.len(),
1059        );
1060        extras.input_dependents = layout.expand_dependents(resolved, &per_input);
1061        extras.attribution = std::sync::Arc::new(Self::attribution_of(resolved));
1062
1063        let mut steps = Vec::with_capacity(resolved.nodes.len());
1064        for (node_idx, (op, scratch)) in compiled_ops.into_iter().enumerate() {
1065            steps.push(crate::compile::closures::P2Step {
1066                name: resolved.nodes[node_idx].meta().name.clone(),
1067                op,
1068                input_slots: layout.input_slots(resolved, node_idx),
1069                output_slots: layout.output_slots(resolved, node_idx),
1070                ref_output_starts: layout.ref_output_starts(resolved, node_idx),
1071                scratch,
1072                accepts_none: resolved.nodes[node_idx].accepts_none_inputs(),
1073                volatile: classes.nondeterministic[node_idx],
1074                constant: classes.lifecycle[node_idx] == crate::kernel::EvalLifecycle::CompileConst,
1075                side: matches!(
1076                    resolved.nodes[node_idx].purity(),
1077                    crate::ast::Purity::SideChannel { .. }
1078                ),
1079            });
1080        }
1081        let output_map = layout.named_outputs(resolved);
1082        let ref_slots = layout.ref_slot_mask(resolved);
1083
1084        Ok((
1085            layout.coord_slots,
1086            layout.total_slots,
1087            steps,
1088            output_map,
1089            ref_slots,
1090            extras,
1091        ))
1092    }
1093
1094    /// Shared: resolve nodes to JIT steps + slot layout.
1095    #[cfg(feature = "jit")]
1096    pub(crate) fn build_jit_layout(resolved: &ResolvedDag) -> Result<JitLayout, String> {
1097        let layout = slot_layout(resolved);
1098
1099        // Every step's scratch entries are placed in the state's
1100        // scratch as the steps are laid out (axiom S3): a reference
1101        // output's pair names its own entry, wherever the step runs.
1102        let mut scratch = crate::compile::jit::ScratchPlan::default();
1103        let mut jit_steps = Vec::new();
1104        for (node_idx, node) in resolved.nodes.iter().enumerate() {
1105            let mut jit_op = crate::compile::jit::classify_node_typed(
1106                node.as_ref(),
1107                &wire_types_of(resolved, node_idx),
1108            );
1109            if matches!(jit_op, crate::compile::jit::JitOp::Fallback) {
1110                return Err(format!(
1111                    "node '{}' has no native form and no kit; pure native code cannot run it",
1112                    node.meta().name
1113                ));
1114            }
1115            let base = scratch.elems.len();
1116            jit_op.place_scratch(base);
1117            let elems = jit_op.scratch_elems().to_vec();
1118            scratch.refs.extend(scratch_pairs(
1119                &node.meta().name,
1120                &layout.ref_output_starts(resolved, node_idx),
1121                &elems,
1122                base,
1123            ));
1124            scratch.elems.extend(elems);
1125            jit_steps.push((
1126                jit_op,
1127                layout.input_slots(resolved, node_idx),
1128                layout.output_slots(resolved, node_idx),
1129            ));
1130        }
1131
1132        let output_map = layout.named_outputs(resolved);
1133        // The runtime model's lifecycle classification, the one rule the
1134        // interpreter's fold applies: a nondeterministic node, or one
1135        // downstream of it, is never current on any engine.
1136        let classes = PolydatProgram::classify_lifecycle(
1137            &resolved.nodes,
1138            &resolved.wiring,
1139            &resolved.input_defs,
1140            &resolved.output_map,
1141            &resolved.output_modifiers,
1142        );
1143        let volatile: Vec<usize> = (0..resolved.nodes.len())
1144            .filter(|&i| classes.nondeterministic[i])
1145            .collect();
1146        Ok((
1147            layout.coord_slots,
1148            layout.total_slots,
1149            jit_steps,
1150            output_map,
1151            scratch,
1152            volatile,
1153        ))
1154    }
1155
1156    /// The slots a pure-P3 kernel's raw readers must refuse and the
1157    /// port type of each named output, for typed decode (SRD 115 §5).
1158    #[cfg(feature = "jit")]
1159    fn jit_slot_info(resolved: &ResolvedDag) -> (Vec<bool>, HashMap<String, PortType>) {
1160        let layout = slot_layout(resolved);
1161        let guard = layout.ref_slot_mask(resolved);
1162        let types = resolved
1163            .output_map
1164            .iter()
1165            .map(|(name, (n, p))| (name.clone(), resolved.nodes[*n].meta().outs[*p].typ))
1166            .collect();
1167        (guard, types)
1168    }
1169
1170    #[cfg(feature = "jit")]
1171    fn jit_push_pull_from(
1172        resolved: ResolvedDag,
1173    ) -> Result<crate::compile::jit::JitKernelPushPull, KernelError> {
1174        let _coord_names = resolved.input_names();
1175        let (coord_count, total_slots, jit_steps, output_map, scratch, volatile) =
1176            Self::build_jit_layout(&resolved).map_err(Self::refused_by_pure_native)?;
1177        let (guard, types) = Self::jit_slot_info(&resolved);
1178        let deps = slot_layout(&resolved).expand_dependents(
1179            &resolved,
1180            &PolydatProgram::compute_dependents(
1181                &PolydatProgram::compute_provenance(&resolved.nodes, &resolved.wiring),
1182                resolved.input_defs.len(),
1183            ),
1184        );
1185        let externs = Self::externs_of(&resolved).map_err(Self::refused_by_pure_native)?;
1186        let attribution = std::sync::Arc::new(Self::attribution_of(&resolved));
1187        let (folded, origin) = Self::constant_steps(&resolved, &jit_steps);
1188        let alone = Self::side_channels(&resolved);
1189        let mut k = crate::compile::jit::compile_jit_push_pull(
1190            coord_count,
1191            total_slots,
1192            jit_steps,
1193            output_map,
1194            resolved.nodes,
1195            deps,
1196            externs,
1197            scratch,
1198            volatile,
1199            alone,
1200        )
1201        .map_err(Self::refused_by_pure_native)?;
1202        k.set_slot_info(guard, types);
1203        k.set_attribution(attribution);
1204        // After the attribution, so a constant that fails at build names
1205        // its node as it would at evaluation.
1206        k.fold_constants(&folded, &origin, total_slots)?;
1207        Ok(k)
1208    }
1209
1210    /// This graph's compile-constant steps, and the program step each
1211    /// one came from. The closure tier and the hybrid run their
1212    /// constant steps out of the step list they keep; the pure tier
1213    /// compiles one function over every step and keeps no list, so its
1214    /// constants are compiled a second time into an entry of their own
1215    /// and run once over the kernel's buffer. Same classification as
1216    /// the other two engines use, from the runtime model's lifecycle.
1217    #[cfg(feature = "jit")]
1218    #[allow(clippy::type_complexity)]
1219    fn constant_steps(
1220        resolved: &ResolvedDag,
1221        jit_steps: &[(crate::compile::jit::JitOp, Vec<usize>, Vec<usize>)],
1222    ) -> (
1223        Vec<(crate::compile::jit::JitOp, Vec<usize>, Vec<usize>)>,
1224        Vec<usize>,
1225    ) {
1226        let classes = PolydatProgram::classify_lifecycle(
1227            &resolved.nodes,
1228            &resolved.wiring,
1229            &resolved.input_defs,
1230            &resolved.output_map,
1231            &resolved.output_modifiers,
1232        );
1233        // One step per node, pushed in node order by `build_jit_layout`,
1234        // so a step's index is its node's.
1235        jit_steps
1236            .iter()
1237            .enumerate()
1238            .filter(|(i, _)| {
1239                classes.lifecycle.get(*i) == Some(&crate::kernel::EvalLifecycle::CompileConst)
1240            })
1241            .map(|(i, s)| (s.clone(), i))
1242            .unzip()
1243    }
1244
1245    /// Per node, and so per pure-native step, whether it is a side
1246    /// channel: such a step is a fusion unit of its own, so it fires
1247    /// when its own inputs change and not whenever a neighbor runs.
1248    #[cfg(feature = "jit")]
1249    fn side_channels(resolved: &ResolvedDag) -> Vec<bool> {
1250        resolved
1251            .nodes
1252            .iter()
1253            .map(|n| matches!(n.purity(), crate::ast::Purity::SideChannel { .. }))
1254            .collect()
1255    }
1256
1257    /// The extern inputs of a resolved graph, at the slots the layout
1258    /// gives them.
1259    fn externs_of(resolved: &ResolvedDag) -> Result<crate::compile::externs::Externs, String> {
1260        let layout = slot_layout(resolved);
1261        let mut externs = crate::compile::externs::Externs::new(
1262            &resolved.input_defs,
1263            resolved.coord_count,
1264            &layout.input_starts,
1265            &resolved.cursor_schemas,
1266            &shared_outputs_of(resolved),
1267            resolved.ledger.clone(),
1268        )?;
1269        externs.set_output_names(&resolved.output_order);
1270        externs.set_output_modifiers(&resolved.output_modifiers);
1271        externs.set_const_inits(&resolved.const_inits);
1272        externs.set_fixed_outputs(fixed_outputs(resolved));
1273        Ok(externs)
1274    }
1275
1276    /// Pure native code, raw; see [`Self::try_compile_pure_jit`].
1277    #[doc(hidden)]
1278    #[cfg(feature = "jit")]
1279    pub(crate) fn try_compile_pure_jit_raw(
1280        self,
1281    ) -> Result<crate::compile::jit::JitKernelRaw, KernelError> {
1282        let resolved = self.resolve().map_err(KernelError::Assembly)?;
1283        Self::jit_raw_from(resolved)
1284    }
1285
1286    // ── The typed tier constructors (feature `bench-tiers`) ──────
1287    //
1288    // The same kernels [`Self::compile_slots`] builds, returned as
1289    // their own types instead of `Box<dyn SlotKernel>`.
1290    //
1291    // There is one contract — [`Kernel`](crate::kernel::Kernel) and the
1292    // [`SlotKernel`](crate::compile::SlotKernel) that extends it — and
1293    // these do not add a second. They change only how a caller *holds*
1294    // it: a boxed kernel dispatches, a named one monomorphizes, and
1295    // both are bound by the same trait with the same semantics.
1296    //
1297    // The normative path is `compile_slots`, which picks an engine from
1298    // a runtime value and therefore cannot return a statically known
1299    // type. Only a caller that knows its tier at compile time can use
1300    // these, and only one kind of caller does: a benchmark measuring a
1301    // tier, which otherwise measures the dispatch instead of the
1302    // kernel. On the engine ladder that difference is about a fifth of
1303    // the native tier's per-cycle cost, which is large enough to hide
1304    // the regressions the ladder exists to catch.
1305    //
1306    // Off by default, so an ordinary build and anything a consumer
1307    // links has exactly one door to a kernel.
1308
1309    /// The closure tier with no provenance, as its own type.
1310    #[cfg(feature = "bench-tiers")]
1311    pub fn compile_closures_raw(
1312        self,
1313    ) -> Result<crate::compile::closures::CompiledKernelRaw, KernelError> {
1314        let resolved = self.resolve_with_log(None)?;
1315        let (coord_count, total_slots, steps, output_map, ref_slots, extras) =
1316            Self::build_p2_layout(&resolved).map_err(Self::refused_by_closures)?;
1317        crate::compile::closures::CompiledKernelRaw::new(
1318            coord_count,
1319            total_slots,
1320            steps,
1321            output_map,
1322            ref_slots,
1323            extras,
1324        )
1325    }
1326
1327    /// The native tier with no provenance, as its own type.
1328    #[cfg(all(feature = "bench-tiers", feature = "jit"))]
1329    pub fn compile_native_raw(
1330        self,
1331    ) -> Result<crate::compile::hybrid::HybridKernelRaw, KernelError> {
1332        let resolved = self.resolve_with_log(None)?;
1333        Ok(Self::hybrid_from(resolved)?.into_raw())
1334    }
1335
1336    /// Pure native code with no provenance, as its own type.
1337    #[cfg(all(feature = "bench-tiers", feature = "jit"))]
1338    pub fn compile_pure_native_raw(self) -> Result<crate::compile::jit::JitKernelRaw, KernelError> {
1339        self.try_compile_pure_jit_raw()
1340    }
1341
1342    /// Where each node lives, for the failure path (A7): its name, the
1343    /// outputs it feeds, and `(first slot, port type)` per input port,
1344    /// so a compiled kernel can report a step's failure as the
1345    /// interpreter reports the node's.
1346    pub(crate) fn attribution_of(resolved: &ResolvedDag) -> crate::compile::Attribution {
1347        let layout = slot_layout(resolved);
1348        let sites = resolved
1349            .nodes
1350            .iter()
1351            .enumerate()
1352            .map(|(node_idx, node)| {
1353                let mut outputs: Vec<String> = resolved
1354                    .output_map
1355                    .iter()
1356                    .filter(|(_, (n, _))| *n == node_idx)
1357                    .map(|(name, _)| name.clone())
1358                    .collect();
1359                outputs.sort();
1360                let inputs = resolved.wiring[node_idx]
1361                    .iter()
1362                    .map(|source| match source {
1363                        WireSource::Input(c) => (
1364                            layout.input_starts.get(*c).copied().unwrap_or(*c),
1365                            resolved
1366                                .input_defs
1367                                .get(*c)
1368                                .map(|d| d.port_type)
1369                                .unwrap_or(PortType::U64),
1370                        ),
1371                        WireSource::NodeOutput(u, p) => (
1372                            layout.port_offsets[*u][*p],
1373                            resolved.nodes[*u].meta().outs[*p].typ,
1374                        ),
1375                    })
1376                    .collect();
1377                crate::compile::NodeSite {
1378                    name: node.meta().name.to_string(),
1379                    outputs,
1380                    inputs,
1381                }
1382            })
1383            .collect();
1384        crate::compile::Attribution {
1385            sites,
1386            context: resolved.context.clone(),
1387        }
1388    }
1389
1390    #[cfg(feature = "jit")]
1391    fn jit_raw_from(
1392        resolved: ResolvedDag,
1393    ) -> Result<crate::compile::jit::JitKernelRaw, KernelError> {
1394        let _coord_names = resolved.input_names();
1395        let (coord_count, total_slots, jit_steps, output_map, scratch, volatile) =
1396            Self::build_jit_layout(&resolved).map_err(Self::refused_by_pure_native)?;
1397        let (guard, types) = Self::jit_slot_info(&resolved);
1398        let externs = Self::externs_of(&resolved).map_err(Self::refused_by_pure_native)?;
1399        let attribution = std::sync::Arc::new(Self::attribution_of(&resolved));
1400        let (folded, origin) = Self::constant_steps(&resolved, &jit_steps);
1401        let alone = Self::side_channels(&resolved);
1402        let mut k = crate::compile::jit::compile_jit_raw_with(
1403            coord_count,
1404            total_slots,
1405            jit_steps,
1406            output_map,
1407            resolved.nodes,
1408            externs,
1409            scratch,
1410            volatile,
1411            alone,
1412        )
1413        .map_err(Self::refused_by_pure_native)?;
1414        k.set_slot_info(guard, types);
1415        k.set_attribution(attribution);
1416        // After the attribution, so a constant that fails at build names
1417        // its node as it would at evaluation.
1418        k.fold_constants(&folded, &origin, total_slots)?;
1419        Ok(k)
1420    }
1421
1422    /// Compile the conservative perfect-ordinal Tier-1 SIMD execution plan.
1423    ///
1424    /// Ordinary `compile()` semantics are unchanged. This explicit surface
1425    /// retains the selected scalar DAG as a fallback and synthesizes a second,
1426    /// register-typed DAG for one named output and driving cursor input.
1427    #[cfg(feature = "jit")]
1428    #[doc(hidden)]
1429    pub fn try_compile_tier1_simd_ordinal(
1430        self,
1431        driving_input: &str,
1432        output: &str,
1433    ) -> Result<
1434        crate::compile::simd_tier1::Tier1SimdExecutor,
1435        crate::compile::simd_tier1::Tier1SimdError,
1436    > {
1437        let resolved = self.resolve().map_err(|error| {
1438            crate::compile::simd_tier1::Tier1SimdError::VectorGraphBuild(error.to_string())
1439        })?;
1440        crate::compile::simd_tier1::compile_tier1_ordinal(resolved, driving_input, output)
1441    }
1442
1443    fn hybrid_from(
1444        resolved: ResolvedDag,
1445    ) -> Result<crate::compile::hybrid::HybridKernel, KernelError> {
1446        let _coord_names = resolved.input_names();
1447        let layout = slot_layout(&resolved);
1448
1449        let output_map = layout.named_outputs(&resolved);
1450        let input_widths: Vec<usize> = resolved
1451            .input_defs
1452            .iter()
1453            .map(|d| d.port_type.slot_width())
1454            .collect();
1455
1456        let ref_slots = layout.ref_slot_mask(&resolved);
1457        let input_types: Vec<PortType> = resolved.input_defs.iter().map(|d| d.port_type).collect();
1458        let externs = Self::externs_of(&resolved).map_err(Self::refused_by_native)?;
1459        let attribution = std::sync::Arc::new(Self::attribution_of(&resolved));
1460        // The runtime model's lifecycle classification, the one rule the
1461        // interpreter's fold applies.
1462        let classes = PolydatProgram::classify_lifecycle(
1463            &resolved.nodes,
1464            &resolved.wiring,
1465            &resolved.input_defs,
1466            &resolved.output_map,
1467            &resolved.output_modifiers,
1468        );
1469        let constant: Vec<bool> = classes
1470            .lifecycle
1471            .iter()
1472            .map(|lc| *lc == crate::kernel::EvalLifecycle::CompileConst)
1473            .collect();
1474        let mut kernel = crate::compile::hybrid::build_hybrid(
1475            &resolved.nodes,
1476            &resolved.wiring,
1477            layout.coord_slots,
1478            layout.total_slots,
1479            &layout.port_offsets,
1480            &layout.input_starts,
1481            &input_widths,
1482            output_map,
1483            ref_slots,
1484            &input_types,
1485            externs,
1486            constant,
1487            classes.nondeterministic,
1488            attribution,
1489        )?;
1490        kernel.retain_nodes(resolved.nodes);
1491        Ok(kernel)
1492    }
1493
1494    /// Internal: validate, resolve wiring, insert adapters, topological sort.
1495    /// Report the compiled form each node has
1496    /// (`CompileEvent::CompileLevelSelected`), a property of the node
1497    /// and its wire types, so the log is the same on every engine
1498    /// (engines.md §7): a native form, a compiled `u64` op, a slot
1499    /// kit, a slot copy, or interpretation only. A node is named by
1500    /// the output it produces when it produces one.
1501    fn log_forms(resolved: &ResolvedDag, log: &mut crate::dsl::events::CompileEventLog) {
1502        for (node_idx, node) in resolved.nodes.iter().enumerate() {
1503            let wire_types = wire_types_of(resolved, node_idx);
1504            // Without the `jit` feature there is no native form to
1505            // report: every node reaches its closure, its slot copy, or
1506            // interpretation, which the arms below name.
1507            #[cfg(feature = "jit")]
1508            let native = !matches!(
1509                crate::compile::jit::classify_node_typed(node.as_ref(), &wire_types),
1510                crate::compile::jit::JitOp::Fallback
1511            );
1512            #[cfg(not(feature = "jit"))]
1513            let native = false;
1514            let level = if native {
1515                "native"
1516            } else {
1517                match node_step_op(node.as_ref(), &wire_types) {
1518                    Some((crate::compile::closures::StepOp::Copy, _)) => "slot copy",
1519                    Some((crate::compile::closures::StepOp::U64(_), _)) => "compiled u64 op",
1520                    Some((crate::compile::closures::StepOp::Slot(_), _)) => "slot kit",
1521                    None => "interpreted",
1522                }
1523            };
1524            let name = resolved
1525                .output_map
1526                .iter()
1527                .find(|(_, (ni, _))| *ni == node_idx)
1528                .map(|(n, _)| n.clone())
1529                .unwrap_or_else(|| node.meta().name.clone());
1530            log.push(crate::dsl::events::CompileEvent::CompileLevelSelected {
1531                node: name,
1532                level: level.to_string(),
1533            });
1534        }
1535    }
1536
1537    /// Close the log with the program's shape
1538    /// (`CompileEvent::Summary`): the resolved node and output counts,
1539    /// the same on every engine, and the constants the build folded,
1540    /// counted from the log itself.
1541    fn log_summary(
1542        log: Option<&mut crate::dsl::events::CompileEventLog>,
1543        nodes: usize,
1544        outputs: usize,
1545    ) {
1546        if let Some(log) = log {
1547            let constants_folded = log
1548                .events()
1549                .iter()
1550                .filter(|e| matches!(e, crate::dsl::events::CompileEvent::ConstantFolded { .. }))
1551                .count();
1552            log.push(crate::dsl::events::CompileEvent::Summary {
1553                nodes,
1554                outputs,
1555                constants_folded,
1556            });
1557        }
1558    }
1559
1560    /// Resolve with no log. Only the pure-native paths take it, and
1561    /// those need code generation, so it is gated as they are.
1562    #[cfg(feature = "jit")]
1563    fn resolve(self) -> Result<ResolvedDag, AssemblyError> {
1564        self.resolve_with_log(None)
1565    }
1566
1567    fn resolve_with_log(
1568        mut self,
1569        mut log: Option<&mut crate::dsl::events::CompileEventLog>,
1570    ) -> Result<ResolvedDag, AssemblyError> {
1571        // Input variance (input_variance.md §4). An input whose type the
1572        // compiler inferred rather than the author declared is *open*:
1573        // by the host's setting it keeps its inferred type, stops the
1574        // build, or takes any value, converted in front of its readers
1575        // by a node placed below. Coordinates are never open: they are
1576        // positioned with `set_inputs` and are always `u64`.
1577        let open: Vec<usize> = self
1578            .input_defs
1579            .iter()
1580            .enumerate()
1581            .filter(|(_, d)| {
1582                d.kind != crate::kernel::InputKind::Coordinate
1583                    && d.type_origin == crate::kernel::TypeOrigin::Inferred
1584                    && d.port_type != PortType::Dyn
1585            })
1586            .map(|(i, _)| i)
1587            .collect();
1588        let variance_level = match self.input_variance {
1589            crate::dsl::compile::InputVariance::Fixed => None,
1590            crate::dsl::compile::InputVariance::Error => {
1591                if !open.is_empty() {
1592                    return Err(AssemblyError::OpenInputs(
1593                        open.iter()
1594                            .map(|&i| {
1595                                (
1596                                    self.input_defs[i].name.clone(),
1597                                    self.input_defs[i].port_type,
1598                                )
1599                            })
1600                            .collect(),
1601                    ));
1602                }
1603                None
1604            }
1605            crate::dsl::compile::InputVariance::Warn => {
1606                Some(crate::dsl::events::EventLevel::Warning)
1607            }
1608            crate::dsl::compile::InputVariance::Info => Some(crate::dsl::events::EventLevel::Info),
1609        };
1610        if variance_level.is_some() {
1611            for &i in &open {
1612                let def = &mut self.input_defs[i];
1613                def.converts_to = Some(def.port_type);
1614                def.port_type = PortType::Dyn;
1615            }
1616        }
1617        // An extern without a default is `None` until the host sets it,
1618        // and every consumer reads `None` through it; the log names each
1619        // one so a host knows what it must set (engines.md §3.3).
1620        // A cursor's slots are `None` until narrowed by design and are
1621        // not externs a host sets by value.
1622        if let Some(log) = log.as_deref_mut() {
1623            let cursor_slot = |name: &str| {
1624                self.cursor_schemas
1625                    .iter()
1626                    .any(|s| name.starts_with(&format!("{}__cursor", s.name)))
1627            };
1628            for def in &self.input_defs {
1629                if matches!(
1630                    def.kind,
1631                    crate::kernel::InputKind::ExternalWrite
1632                        | crate::kernel::InputKind::IterationExtern
1633                ) && def.default == crate::ast::Value::None
1634                    && !cursor_slot(&def.name)
1635                {
1636                    log.push(crate::dsl::events::CompileEvent::ExternWithoutDefault {
1637                        name: def.name.clone(),
1638                        port_type: def.port_type.to_string(),
1639                    });
1640                }
1641            }
1642        }
1643        // Build name → index map for nodes
1644        let mut name_to_idx: HashMap<String, usize> = HashMap::new();
1645        for (i, pn) in self.nodes.iter().enumerate() {
1646            if name_to_idx.contains_key(&pn.name) {
1647                return Err(AssemblyError::DuplicateNode(pn.name.clone()));
1648            }
1649            name_to_idx.insert(pn.name.clone(), i);
1650        }
1651
1652        // Build input name → index map (covers both coords and captures)
1653        let input_to_idx: HashMap<String, usize> = self
1654            .input_defs
1655            .iter()
1656            .enumerate()
1657            .map(|(i, d)| (d.name.clone(), i))
1658            .collect();
1659
1660        // Validate arity
1661        for pn in &self.nodes {
1662            let expected = pn.node.meta().wire_inputs().len();
1663            let got = pn.inputs.len();
1664            if expected != got {
1665                return Err(AssemblyError::ArityMismatch {
1666                    node_name: pn.name.clone(),
1667                    expected,
1668                    got,
1669                });
1670            }
1671        }
1672
1673        let mut all_nodes: Vec<PendingNode> = Vec::new();
1674        let mut all_name_to_idx: HashMap<String, usize> = HashMap::new();
1675        let mut adapter_count = 0usize;
1676        let mut assertion_count = 0usize;
1677        let strict_values = self.strict_values;
1678        let strict_types = self.strict_types;
1679        let strict = self.strict;
1680
1681        for pn in self.nodes {
1682            let idx = all_nodes.len();
1683            all_name_to_idx.insert(pn.name.clone(), idx);
1684            all_nodes.push(pn);
1685        }
1686
1687        let mut resolved_wiring: Vec<Vec<WireSource>> = Vec::new();
1688        // One converter per (input, target type), shared by its readers.
1689        let mut converters: HashMap<(usize, PortType), usize> = HashMap::new();
1690
1691        for node_idx in 0..all_nodes.len() {
1692            let mut node_wiring = Vec::new();
1693
1694            for (port_idx, wire_ref) in all_nodes[node_idx].inputs.clone().iter().enumerate() {
1695                let port = all_nodes[node_idx].node.meta().wire_inputs()[port_idx].clone();
1696                let expected_type = port.typ;
1697
1698                let (source, source_type) = match wire_ref {
1699                    WireRef::Input(name) => {
1700                        let input_idx = input_to_idx
1701                            .get(name)
1702                            .ok_or_else(|| AssemblyError::UnknownWire(name.clone()))?;
1703                        let source_type = self.input_defs[*input_idx].port_type;
1704                        (WireSource::Input(*input_idx), source_type)
1705                    }
1706                    WireRef::Node(name, out_port) => {
1707                        let src_idx = all_name_to_idx
1708                            .get(name)
1709                            .ok_or_else(|| AssemblyError::UnknownWire(name.clone()))?;
1710                        let src_type = all_nodes[*src_idx].node.meta().outs[*out_port].typ;
1711                        (WireSource::NodeOutput(*src_idx, *out_port), src_type)
1712                    }
1713                };
1714
1715                // A port that takes the wire as it is gets no
1716                // adapter and no check: converting the value would
1717                // change what the node reads. The port says so
1718                // itself (`Port::accepts_any_type`) — this used to be
1719                // decided from a list of thirteen node names, which
1720                // disabled the check on every port of those nodes,
1721                // `pick`'s `Bool` selectors included.
1722                //
1723                // A `Dyn` input feeding a typed port reads through a
1724                // converter to that type (input_variance.md §5), placed
1725                // once per input and type and shared by every reader.
1726                if source_type == PortType::Dyn
1727                    && !port.accepts_any_type
1728                    && expected_type != PortType::Dyn
1729                {
1730                    let WireSource::Input(input_idx) = source else {
1731                        unreachable!("only an input slot is typed `Dyn`")
1732                    };
1733                    let conv_idx = match converters.get(&(input_idx, expected_type)) {
1734                        Some(&idx) => idx,
1735                        None => {
1736                            let converter = crate::convert::InputConverter::new(
1737                                &self.input_defs[input_idx].name,
1738                                expected_type,
1739                            );
1740                            let conv_name = converter.meta().name.clone();
1741                            let idx = all_nodes.len();
1742                            all_name_to_idx.insert(conv_name.clone(), idx);
1743                            while resolved_wiring.len() <= idx {
1744                                resolved_wiring.push(Vec::new());
1745                            }
1746                            resolved_wiring[idx] = vec![source.clone()];
1747                            all_nodes.push(PendingNode {
1748                                name: conv_name,
1749                                node: Box::new(converter),
1750                                inputs: vec![],
1751                            });
1752                            converters.insert((input_idx, expected_type), idx);
1753                            idx
1754                        }
1755                    };
1756                    node_wiring.push(WireSource::NodeOutput(conv_idx, 0));
1757                } else if port.accepts_any_type || source_type == expected_type {
1758                    node_wiring.push(source);
1759                } else if let Some(adapter) = auto_adapter(source_type, expected_type) {
1760                    if strict {
1761                        return Err(AssemblyError::Other(format!(
1762                            "strict mode: implicit type coercion {source_type} → {expected_type} \
1763                             into '{}'. Use an explicit conversion function (e.g., to_f64, \
1764                             to_i64, f64_to_u64).",
1765                            all_nodes[node_idx].name
1766                        )));
1767                    }
1768                    let adapter_name = format!("__adapt_{adapter_count}");
1769                    adapter_count += 1;
1770                    let adapter_idx = all_nodes.len();
1771
1772                    if let Some(ref mut log) = log {
1773                        let from_name = match wire_ref {
1774                            WireRef::Input(n) => n.clone(),
1775                            WireRef::Node(n, _) => n.clone(),
1776                        };
1777                        let to_name = all_nodes[node_idx].name.clone();
1778                        log.push(if is_lossless_widening(source_type, expected_type) {
1779                            crate::dsl::events::CompileEvent::TypeWidening {
1780                                from: source_type.to_keyword(),
1781                                to: expected_type.to_keyword(),
1782                                context: format!("{from_name} → {to_name}"),
1783                            }
1784                        } else {
1785                            crate::dsl::events::CompileEvent::TypeAdapterInserted {
1786                                from_node: from_name,
1787                                to_node: to_name,
1788                                adapter: format!("{source_type:?}→{expected_type:?}"),
1789                            }
1790                        });
1791                    }
1792
1793                    all_name_to_idx.insert(adapter_name.clone(), adapter_idx);
1794
1795                    let adapter_wiring = vec![source];
1796                    while resolved_wiring.len() <= adapter_idx {
1797                        resolved_wiring.push(Vec::new());
1798                    }
1799                    resolved_wiring[adapter_idx] = adapter_wiring;
1800
1801                    all_nodes.push(PendingNode {
1802                        name: adapter_name,
1803                        node: adapter,
1804                        inputs: vec![],
1805                    });
1806
1807                    node_wiring.push(WireSource::NodeOutput(adapter_idx, 0));
1808                } else {
1809                    let from_name = match wire_ref {
1810                        WireRef::Input(n) => n.clone(),
1811                        WireRef::Node(n, _) => n.clone(),
1812                    };
1813                    return Err(AssemblyError::TypeMismatch {
1814                        from_node: from_name,
1815                        from_port: match wire_ref {
1816                            WireRef::Input(_) => 0,
1817                            WireRef::Node(_, p) => *p,
1818                        },
1819                        from_type: source_type,
1820                        to_node: all_nodes[node_idx].name.clone(),
1821                        to_port: port_idx,
1822                        to_type: expected_type,
1823                    });
1824                }
1825
1826                // === Strict-wire assertion insertion (SRD 15) ===
1827                //
1828                // After a wire is resolved (and any type adapter
1829                // inserted), look at the sink port's declared
1830                // `constraint`. If strict_values is on, we either
1831                // prove the source already satisfies it (skip) or
1832                // splice an `AssertValue` node in front of the
1833                // sink. The skip cases mirror the four bullets in
1834                // SRD 15 §"Strict Wire Mode": static type match is
1835                // already handled by the adapter pass above; here
1836                // we cover constant sources and upstream-assertion
1837                // chains for value constraints.
1838                let sink_port = &all_nodes[node_idx].node.meta().wire_inputs()[port_idx];
1839                if let Some(constraint) = sink_port.constraint {
1840                    let last_source = node_wiring.last().expect("wire just pushed").clone();
1841                    if strict_values
1842                        && !value_constraint_proven(&all_nodes, &last_source, &constraint)
1843                    {
1844                        let assert_name = format!("__assert_v_{assertion_count}");
1845                        assertion_count += 1;
1846                        let assert_idx = all_nodes.len();
1847
1848                        if let Some(ref mut log) = log {
1849                            let from_name = match wire_ref {
1850                                WireRef::Input(n) => n.clone(),
1851                                WireRef::Node(n, _) => n.clone(),
1852                            };
1853                            log.push(crate::dsl::events::CompileEvent::AssertionInserted {
1854                                from_node: from_name,
1855                                to_node: all_nodes[node_idx].name.clone(),
1856                                kind: format!("{:?} value-assert {:?}", expected_type, constraint),
1857                            });
1858                        }
1859
1860                        all_name_to_idx.insert(assert_name.clone(), assert_idx);
1861                        let assert_wiring = vec![last_source];
1862                        while resolved_wiring.len() <= assert_idx {
1863                            resolved_wiring.push(Vec::new());
1864                        }
1865                        resolved_wiring[assert_idx] = assert_wiring;
1866
1867                        all_nodes.push(PendingNode {
1868                            name: assert_name,
1869                            node: crate::library::assertions::assert_value_node(
1870                                expected_type,
1871                                constraint,
1872                            ),
1873                            inputs: vec![],
1874                        });
1875
1876                        // Replace the just-pushed source with the
1877                        // assertion's output.
1878                        *node_wiring.last_mut().unwrap() = WireSource::NodeOutput(assert_idx, 0);
1879                    } else if let Some(ref mut log) = log {
1880                        let from_name = match wire_ref {
1881                            WireRef::Input(n) => n.clone(),
1882                            WireRef::Node(n, _) => n.clone(),
1883                        };
1884                        log.push(crate::dsl::events::CompileEvent::AssertionSkipped {
1885                            from_node: from_name,
1886                            to_node: all_nodes[node_idx].name.clone(),
1887                            reason: assertion_skip_reason(
1888                                strict_values,
1889                                &all_nodes,
1890                                &last_source,
1891                                &constraint,
1892                            ),
1893                        });
1894                    }
1895                } else if strict_types && source_type != expected_type {
1896                    // Type mismatch was already adapted above; the
1897                    // post-adapter wire is statically the right
1898                    // type. No assertion needed. Tracking the skip
1899                    // here is forward-compatible — once dynamic
1900                    // type cases (JSON nav, Ext unwraps) appear,
1901                    // this is where the AssertType insertion would
1902                    // hook in.
1903                }
1904            }
1905
1906            while resolved_wiring.len() <= node_idx {
1907                resolved_wiring.push(Vec::new());
1908            }
1909            resolved_wiring[node_idx] = node_wiring;
1910        }
1911
1912        while resolved_wiring.len() < all_nodes.len() {
1913            resolved_wiring.push(Vec::new());
1914        }
1915
1916        // --- Node fusion optimization ---
1917        //
1918        // Recognize fusible subgraph patterns and replace them with
1919        // semantically equivalent fused nodes. See SRD 36.
1920        {
1921            let rules = crate::compile::fusion::default_rules();
1922            if !rules.is_empty() {
1923                // Collect node indices that are directly referenced by outputs.
1924                // These nodes must not be consumed as interior nodes by fusion.
1925                let mut output_nodes: Vec<usize> = Vec::new();
1926                for wire_ref in self.outputs.values() {
1927                    if let WireRef::Node(node_name, _) = wire_ref
1928                        && let Some(&idx) = all_name_to_idx.get(node_name)
1929                    {
1930                        output_nodes.push(idx);
1931                    }
1932                }
1933
1934                // Convert to Option<Box<dyn PolydatNode>> for the fusion pass.
1935                let mut opt_nodes: Vec<Option<Box<dyn PolydatNode>>> =
1936                    all_nodes.into_iter().map(|pn| Some(pn.node)).collect();
1937
1938                let fused_count = crate::compile::fusion::apply_fusions(
1939                    &mut opt_nodes,
1940                    &mut resolved_wiring,
1941                    &mut all_name_to_idx,
1942                    &rules,
1943                    &output_nodes,
1944                );
1945                if fused_count > 0
1946                    && let Some(ref mut log) = log
1947                {
1948                    log.push(crate::dsl::events::CompileEvent::FusionApplied {
1949                        pattern: "subgraph".into(),
1950                        nodes_replaced: fused_count,
1951                    });
1952                }
1953
1954                // Convert back, rebuilding PendingNode wrappers.
1955                // Fused-away nodes (None) get placeholder names.
1956                all_nodes = opt_nodes
1957                    .into_iter()
1958                    .enumerate()
1959                    .map(|(i, opt)| PendingNode {
1960                        name: all_name_to_idx
1961                            .iter()
1962                            .find(|&(_, &idx)| idx == i)
1963                            .map(|(n, _)| n.clone())
1964                            .unwrap_or_else(|| format!("__removed_{i}")),
1965                        node: opt.unwrap_or_else(|| {
1966                            Box::new(crate::library::identity::Identity::new(
1967                                crate::ast::PortType::U64,
1968                            ))
1969                        }),
1970                        inputs: vec![], // wiring is in resolved_wiring
1971                    })
1972                    .collect();
1973            }
1974        }
1975
1976        // --- Dead code elimination ---
1977        //
1978        // Trace backward from output nodes to find all reachable nodes.
1979        // Only reachable nodes participate in the topological sort and
1980        // end up in the final kernel. This prunes unused binding chains
1981        // when the caller requests a subset of outputs.
1982        let node_count = all_nodes.len();
1983        let mut reachable = vec![false; node_count];
1984        {
1985            let mut worklist: Vec<usize> = Vec::new();
1986            // Seed with output nodes
1987            for wire_ref in self.outputs.values() {
1988                if let WireRef::Node(node_name, _) = wire_ref
1989                    && let Some(&idx) = all_name_to_idx.get(node_name)
1990                {
1991                    worklist.push(idx);
1992                }
1993            }
1994            // Side-effecting nodes are pinned alive regardless
1995            // of reachability from a declared output. `log_info`
1996            // and friends emit one audit-log line per eval as a
1997            // deliberate side effect — DCE-pruning them would
1998            // silently drop diagnostic logging the operator
1999            // explicitly asked for. The set is closed and
2000            // matched by node-meta name so the marker survives
2001            // any wiring shape (passthrough, captured-but-unused,
2002            // synthesised wrapper, etc.).
2003            for (idx, pn) in all_nodes.iter().enumerate() {
2004                if matches!(
2005                    pn.node.meta().name.as_str(),
2006                    "log_debug" | "log_info" | "log_warn" | "log_error"
2007                ) {
2008                    worklist.push(idx);
2009                }
2010            }
2011            // Walk backward through wiring
2012            while let Some(idx) = worklist.pop() {
2013                if reachable[idx] {
2014                    continue;
2015                }
2016                reachable[idx] = true;
2017                for source in &resolved_wiring[idx] {
2018                    if let WireSource::NodeOutput(upstream, _) = source
2019                        && !reachable[*upstream]
2020                    {
2021                        worklist.push(*upstream);
2022                    }
2023                }
2024            }
2025        }
2026        let live_count = reachable.iter().filter(|&&r| r).count();
2027
2028        // Every converter that survived pruning is reported at the level
2029        // the host asked for (input_variance.md §4), in the order it was
2030        // placed; an input the setting opened that nothing reads is
2031        // reported once too, so no opened input goes unseen.
2032        if let Some(log) = log.as_deref_mut() {
2033            let mut placed: Vec<(usize, usize, PortType)> = converters
2034                .iter()
2035                .filter(|&(_, &idx)| reachable[idx])
2036                .map(|(&(input, to), &idx)| (idx, input, to))
2037                .collect();
2038            placed.sort_unstable_by_key(|&(idx, _, _)| idx);
2039            for &(idx, input, to) in &placed {
2040                let def = &self.input_defs[input];
2041                let (origin, level) = match (def.converts_to, variance_level) {
2042                    (Some(_), Some(level)) => ("inferred", level),
2043                    _ => ("declared dyn", crate::dsl::events::EventLevel::Info),
2044                };
2045                log.push(crate::dsl::events::CompileEvent::InputConverterInserted {
2046                    input: def.name.clone(),
2047                    to: to.to_keyword().to_string(),
2048                    node: all_nodes[idx].name.clone(),
2049                    origin: origin.to_string(),
2050                    level,
2051                });
2052            }
2053            if let Some(level) = variance_level {
2054                for &input in &open {
2055                    if placed.iter().any(|&(_, i, _)| i == input) {
2056                        continue;
2057                    }
2058                    let def = &self.input_defs[input];
2059                    log.push(crate::dsl::events::CompileEvent::InputConverterInserted {
2060                        input: def.name.clone(),
2061                        to: def
2062                            .converts_to
2063                            .unwrap_or(def.port_type)
2064                            .to_keyword()
2065                            .to_string(),
2066                        node: "(none: nothing reads it)".to_string(),
2067                        origin: "inferred".to_string(),
2068                        level,
2069                    });
2070                }
2071            }
2072        }
2073
2074        // Topological sort (Kahn's algorithm) over reachable nodes only
2075        let mut in_degree = vec![0usize; node_count];
2076        let mut dependents: Vec<Vec<usize>> = vec![Vec::new(); node_count];
2077
2078        for (node_idx, wiring) in resolved_wiring.iter().enumerate() {
2079            if !reachable[node_idx] {
2080                continue;
2081            }
2082            for source in wiring {
2083                if let WireSource::NodeOutput(upstream, _) = source {
2084                    in_degree[node_idx] += 1;
2085                    dependents[*upstream].push(node_idx);
2086                }
2087            }
2088        }
2089
2090        let mut queue: Vec<usize> = (0..node_count)
2091            .filter(|i| reachable[*i] && in_degree[*i] == 0)
2092            .collect();
2093        let mut sorted_order: Vec<usize> = Vec::with_capacity(live_count);
2094
2095        while let Some(idx) = queue.pop() {
2096            sorted_order.push(idx);
2097            for &dep in &dependents[idx] {
2098                in_degree[dep] -= 1;
2099                if in_degree[dep] == 0 {
2100                    queue.push(dep);
2101                }
2102            }
2103        }
2104
2105        if sorted_order.len() != live_count {
2106            return Err(AssemblyError::CycleDetected);
2107        }
2108
2109        let mut old_to_new = vec![0usize; node_count];
2110        for (new_idx, &old_idx) in sorted_order.iter().enumerate() {
2111            old_to_new[old_idx] = new_idx;
2112        }
2113
2114        let mut sorted_nodes: Vec<Option<Box<dyn PolydatNode>>> =
2115            all_nodes.into_iter().map(|pn| Some(pn.node)).collect();
2116
2117        let final_nodes: Vec<Box<dyn PolydatNode>> = sorted_order
2118            .iter()
2119            .map(|&old_idx| sorted_nodes[old_idx].take().unwrap())
2120            .collect();
2121
2122        let final_wiring: Vec<Vec<WireSource>> = sorted_order
2123            .iter()
2124            .map(|&old_idx| {
2125                resolved_wiring[old_idx]
2126                    .iter()
2127                    .map(|source| match source {
2128                        WireSource::Input(c) => WireSource::Input(*c),
2129                        WireSource::NodeOutput(old_up, port) => {
2130                            WireSource::NodeOutput(old_to_new[*old_up], *port)
2131                        }
2132                    })
2133                    .collect()
2134            })
2135            .collect();
2136
2137        let mut final_output_map: HashMap<String, (usize, usize)> = HashMap::new();
2138        for (name, wire_ref) in &self.outputs {
2139            match wire_ref {
2140                WireRef::Input(coord_name) => {
2141                    return Err(AssemblyError::UnknownWire(format!(
2142                        "output '{name}' references coordinate '{coord_name}' directly; \
2143                         wire through a node instead"
2144                    )));
2145                }
2146                WireRef::Node(node_name, port) => {
2147                    let old_idx = all_name_to_idx
2148                        .get(node_name)
2149                        .ok_or_else(|| AssemblyError::UnknownWire(node_name.clone()))?;
2150                    final_output_map.insert(name.clone(), (old_to_new[*old_idx], *port));
2151                }
2152            }
2153        }
2154
2155        // C6b — structural type-round-trip lint (see
2156        // `compile::roundtrip_lint`): a value modulated `T → Y → … → T`
2157        // through pure conversion/formatting machinery violates the
2158        // native-types-stay-native principle. Warning by default; a
2159        // hard error under strict-values mode, matching the SRD 15
2160        // strict-wire constraint discipline.
2161        for f in crate::compile::roundtrip_lint::lint_type_round_trips(
2162            &final_nodes,
2163            &final_wiring,
2164            &self.input_defs,
2165        ) {
2166            if strict_values {
2167                return Err(AssemblyError::Other(f.message()));
2168            }
2169            // Through the audit log, which the host routes; a library
2170            // does not write to the process's stderr on its own.
2171            crate::library::support::audit::warn(&f.message());
2172            if let Some(ref mut log) = log {
2173                log.push(crate::dsl::events::CompileEvent::Warning {
2174                    message: f.message(),
2175                });
2176            }
2177        }
2178
2179        if let Some(log) = log {
2180            let resolved_view = ResolvedDag {
2181                nodes: final_nodes,
2182                wiring: final_wiring,
2183                input_defs: self.input_defs,
2184                coord_count: self.coord_count,
2185                output_map: final_output_map,
2186                output_order: self.output_order,
2187                source: self.source,
2188                context: self.context,
2189                output_modifiers: self.output_modifiers,
2190                const_outputs: self.const_outputs,
2191                const_inits: self.const_inits,
2192                cursor_schemas: self.cursor_schemas,
2193                ledger: self.ledger,
2194            };
2195            Self::log_forms(&resolved_view, log);
2196            return Ok(resolved_view);
2197        }
2198        Ok(ResolvedDag {
2199            nodes: final_nodes,
2200            wiring: final_wiring,
2201            input_defs: self.input_defs,
2202            coord_count: self.coord_count,
2203            output_map: final_output_map,
2204            output_order: self.output_order,
2205            source: self.source,
2206            context: self.context,
2207            output_modifiers: self.output_modifiers,
2208            const_outputs: self.const_outputs,
2209            const_inits: self.const_inits,
2210            cursor_schemas: self.cursor_schemas,
2211            ledger: self.ledger,
2212        })
2213    }
2214}
2215
2216/// Decide whether the source feeding `wire_source` already
2217/// guarantees the sink's value `constraint` at compile time.
2218/// Returns `true` if the assertion can be safely skipped.
2219///
2220/// Today we recognise two skip cases (SRD 15 §"Strict Wire Mode"):
2221///
2222/// 1. **Constant source.** The source node has no wire inputs and
2223///    its name matches the convention used by `fixed::ConstU64`
2224///    et al. Const sources have already been validated against
2225///    their `ParamSpec.constraint` at the factory layer, so any
2226///    further runtime check would be redundant.
2227/// 2. **Upstream assertion.** The source is itself an
2228///    `AssertValue` node (its name starts with `__assert_v_`),
2229///    which already enforces the same or stronger contract.
2230fn value_constraint_proven(
2231    all_nodes: &[PendingNode],
2232    src: &WireSource,
2233    _constraint: &crate::dsl::const_constraints::ConstConstraint,
2234) -> bool {
2235    match src {
2236        WireSource::Input(_) => false,
2237        WireSource::NodeOutput(idx, _) => {
2238            let meta = all_nodes[*idx].node.meta();
2239            // Const-source heuristic: a node with no wire inputs
2240            // is a constant. Today's `ConstU64` / `ConstF64` /
2241            // `ConstBool` (in `nodes::fixed`) and the synthesised
2242            // `ConstNode` from compile-time folding both qualify.
2243            let no_wire_inputs = meta.wire_inputs().is_empty();
2244            if no_wire_inputs {
2245                return true;
2246            }
2247            // Upstream assertion: skip stacking the same guard.
2248            // Conservative — any `__assert_v_*` upstream counts as
2249            // proof. A fancier analysis would compare constraint
2250            // shapes; for now, idempotency is good enough.
2251            if meta.name.starts_with("__assert_v_") || meta.name.starts_with("assert_") {
2252                return true;
2253            }
2254            false
2255        }
2256    }
2257}
2258
2259/// Format the reason a strict-wire assertion was skipped, for the
2260/// `AssertionSkipped` advisory event. Mirrors the bullets in SRD 15
2261/// §"Strict Wire Mode" so the log is grep-able.
2262fn assertion_skip_reason(
2263    strict_values: bool,
2264    all_nodes: &[PendingNode],
2265    src: &WireSource,
2266    _constraint: &crate::dsl::const_constraints::ConstConstraint,
2267) -> String {
2268    if !strict_values {
2269        return "strict_values not enabled".into();
2270    }
2271    match src {
2272        WireSource::Input(_) => "raw input wire".into(),
2273        WireSource::NodeOutput(idx, _) => {
2274            let meta = all_nodes[*idx].node.meta();
2275            if meta.wire_inputs().is_empty() {
2276                "constant source already validated".into()
2277            } else if meta.name.starts_with("__assert_v_") || meta.name.starts_with("assert_") {
2278                "upstream assertion".into()
2279            } else {
2280                "no skip rule matched".into()
2281            }
2282        }
2283    }
2284}
2285
2286/// The `shared` bindings of a resolved graph, by name: each is an
2287/// extern the compiled kernels bind to a cell (engine parity, step 9).
2288pub(crate) fn shared_outputs_of(resolved: &ResolvedDag) -> Vec<&str> {
2289    let mut shared: Vec<&str> = resolved
2290        .output_modifiers
2291        .iter()
2292        .filter(|(_, m)| **m == crate::dsl::ast::BindingModifier::SHARED)
2293        .map(|(name, _)| name.as_str())
2294        .collect();
2295    shared.sort();
2296    shared
2297}
2298
2299/// The port type of each wire input of a node, from its sources: the
2300/// type a compiled lowering sees (SRD 115 §6).
2301/// Whether the adapter from `from` to `to` is a lossless numeric
2302/// widening, the class the adapter table lists first: reported as a
2303/// `TypeWidening`, where every other adapter is a `TypeAdapterInserted`.
2304fn is_lossless_widening(from: PortType, to: PortType) -> bool {
2305    use PortType as P;
2306    matches!(
2307        (from, to),
2308        (P::U64, P::F64)
2309            | (P::U32, P::U64)
2310            | (P::U32, P::I64)
2311            | (P::U32, P::F64)
2312            | (P::I32, P::I64)
2313            | (P::I32, P::F64)
2314            | (P::I64, P::F64)
2315            | (P::F32, P::F64)
2316    )
2317}
2318
2319pub(crate) fn wire_types_of(resolved: &ResolvedDag, node_idx: usize) -> Vec<PortType> {
2320    resolved.wiring[node_idx]
2321        .iter()
2322        .map(|src| match src {
2323            crate::kernel::WireSource::Input(i) => resolved.input_defs[*i].port_type,
2324            crate::kernel::WireSource::NodeOutput(j, p) => resolved.nodes[*j].meta().outs[*p].typ,
2325        })
2326        .collect()
2327}
2328
2329/// The lossless adapter node from one port type to another, if the
2330/// catalog has one: what the assembler inserts between a wire and a port
2331/// of different types.
2332pub fn auto_adapter(from: PortType, to: PortType) -> Option<Box<dyn PolydatNode>> {
2333    use crate::library::convert::{
2334        BoolToStr, BoolToU64, F32ToF64, F32ToString, I32ToF64, I32ToI64, I32ToString, I64ToF64,
2335        I64ToString, U32ToF64, U32ToI64, U32ToString, U32ToU64,
2336    };
2337    use crate::library::polyfill as P;
2338    use crate::library::polyfill_128 as W;
2339    use crate::library::polyfill_complete as C;
2340    use crate::library::polyfill_narrow as N;
2341    match (from, to) {
2342        // ── Numeric widening (lossless) ─────────────────────────
2343        (PortType::U64, PortType::F64) => Some(Box::new(U64ToF64::new())),
2344        (PortType::U32, PortType::U64) => Some(Box::new(U32ToU64::new())),
2345        (PortType::U32, PortType::I64) => Some(Box::new(U32ToI64::new())),
2346        (PortType::U32, PortType::F64) => Some(Box::new(U32ToF64::new())),
2347        (PortType::I32, PortType::I64) => Some(Box::new(I32ToI64::new())),
2348        (PortType::I32, PortType::F64) => Some(Box::new(I32ToF64::new())),
2349        // Rounds past 2^24 and never fails, which is class A —
2350        // totality, not losslessness. The node existed in `polyfill`
2351        // and the element-wise `VecI32 -> VecF32` below was already
2352        // auto-inserted; only this wiring was missing, so the scalar
2353        // of the same two types fell through to a type mismatch.
2354        (PortType::I32, PortType::F32) => Some(Box::new(P::I32ToF32::new())),
2355        (PortType::I64, PortType::F64) => Some(Box::new(I64ToF64::new())),
2356        (PortType::F32, PortType::F64) => Some(Box::new(F32ToF64::new())),
2357
2358        // ── X → Str (every type renders as a string) ────────────
2359        (PortType::U64, PortType::Str) => Some(Box::new(U64ToString::new())),
2360        (PortType::F64, PortType::Str) => Some(Box::new(F64ToString::new())),
2361        (PortType::Bool, PortType::Str) => Some(Box::new(BoolToStr::new())),
2362        (PortType::Json, PortType::Str) => Some(Box::new(JsonToStr::new())),
2363        (PortType::U32, PortType::Str) => Some(Box::new(U32ToString::new())),
2364        (PortType::I32, PortType::Str) => Some(Box::new(I32ToString::new())),
2365        (PortType::I64, PortType::Str) => Some(Box::new(I64ToString::new())),
2366        (PortType::F32, PortType::Str) => Some(Box::new(F32ToString::new())),
2367
2368        // ── Bool ↔ numeric (always-defined; 1/0 mapping) ────────
2369        (PortType::Bool, PortType::U64) => Some(Box::new(BoolToU64::new())),
2370        (PortType::Bool, PortType::U32) => Some(Box::new(P::BoolToU32::new())),
2371        (PortType::Bool, PortType::I64) => Some(Box::new(P::BoolToI64::new())),
2372        (PortType::Bool, PortType::I32) => Some(Box::new(P::BoolToI32::new())),
2373        (PortType::Bool, PortType::F64) => Some(Box::new(P::BoolToF64::new())),
2374        (PortType::Bool, PortType::F32) => Some(Box::new(P::BoolToF32::new())),
2375        (PortType::U64, PortType::Bool) => {
2376            Some(Box::new(crate::library::convert::U64ToBool::new()))
2377        }
2378        (PortType::U32, PortType::Bool) => Some(Box::new(P::U32ToBool::new())),
2379        (PortType::I64, PortType::Bool) => Some(Box::new(P::I64ToBool::new())),
2380        (PortType::I32, PortType::Bool) => Some(Box::new(P::I32ToBool::new())),
2381        (PortType::F64, PortType::Bool) => Some(Box::new(P::F64ToBool::new())),
2382        (PortType::F32, PortType::Bool) => Some(Box::new(P::F32ToBool::new())),
2383
2384        // ── X → Bytes (little-endian serialize, always-defined) ─
2385        (PortType::U64, PortType::Bytes) => Some(Box::new(P::U64ToBytes::new())),
2386        (PortType::U32, PortType::Bytes) => Some(Box::new(P::U32ToBytes::new())),
2387        (PortType::I64, PortType::Bytes) => Some(Box::new(P::I64ToBytes::new())),
2388        (PortType::I32, PortType::Bytes) => Some(Box::new(P::I32ToBytes::new())),
2389        (PortType::F64, PortType::Bytes) => Some(Box::new(P::F64ToBytes::new())),
2390        (PortType::F32, PortType::Bytes) => Some(Box::new(P::F32ToBytes::new())),
2391        (PortType::Bool, PortType::Bytes) => Some(Box::new(P::BoolToBytes::new())),
2392        (PortType::VecF32, PortType::Bytes) => Some(Box::new(P::VecF32ToBytes::new())),
2393        (PortType::VecI32, PortType::Bytes) => Some(Box::new(P::VecI32ToBytes::new())),
2394
2395        // ── X → Json (integer / bool wraps; F* and VecF32 are
2396        //              boundary-only because non-finite floats
2397        //              aren't representable in JSON) ────────────
2398        (PortType::U64, PortType::Json) => Some(Box::new(P::U64ToJson::new())),
2399        (PortType::U32, PortType::Json) => Some(Box::new(P::U32ToJson::new())),
2400        (PortType::I64, PortType::Json) => Some(Box::new(P::I64ToJson::new())),
2401        (PortType::I32, PortType::Json) => Some(Box::new(P::I32ToJson::new())),
2402        (PortType::Bool, PortType::Json) => Some(Box::new(P::BoolToJson::new())),
2403        (PortType::VecI32, PortType::Json) => Some(Box::new(P::VecI32ToJson::new())),
2404
2405        // ── Vec ↔ Vec (VecI32 → VecF32 is lossless) ─────────────
2406        (PortType::VecI32, PortType::VecF32) => Some(Box::new(P::VecI32ToVecF32::new())),
2407
2408        // ── Narrow cranelift widths (u8/i8/u16/i16/f16) ─────────
2409        // Lossless widenings + Display renders + Bool maps + LE
2410        // byte / JSON wraps, mirroring the u32/i32/f32 rows.
2411        // (type_system_alignment.md §2)
2412        (PortType::U8, PortType::U64) => Some(Box::new(N::U8ToU64::new())),
2413        (PortType::U8, PortType::U32) => Some(Box::new(N::U8ToU32::new())),
2414        (PortType::U8, PortType::U16) => Some(Box::new(N::U8ToU16::new())),
2415        (PortType::U8, PortType::F64) => Some(Box::new(N::U8ToF64::new())),
2416        (PortType::U16, PortType::U64) => Some(Box::new(N::U16ToU64::new())),
2417        (PortType::U16, PortType::U32) => Some(Box::new(N::U16ToU32::new())),
2418        (PortType::U16, PortType::F64) => Some(Box::new(N::U16ToF64::new())),
2419        (PortType::I8, PortType::I64) => Some(Box::new(N::I8ToI64::new())),
2420        (PortType::I8, PortType::I32) => Some(Box::new(N::I8ToI32::new())),
2421        (PortType::I8, PortType::I16) => Some(Box::new(N::I8ToI16::new())),
2422        (PortType::I8, PortType::F64) => Some(Box::new(N::I8ToF64::new())),
2423        (PortType::I16, PortType::I64) => Some(Box::new(N::I16ToI64::new())),
2424        (PortType::I16, PortType::I32) => Some(Box::new(N::I16ToI32::new())),
2425        (PortType::I16, PortType::F64) => Some(Box::new(N::I16ToF64::new())),
2426        (PortType::F16, PortType::F32) => Some(Box::new(N::F16ToF32::new())),
2427        (PortType::F16, PortType::F64) => Some(Box::new(N::F16ToF64::new())),
2428        // Totality fills: unsigned → strictly-larger signed, and
2429        // narrow int → f32 (exact, magnitude ≤ 2^24). All class A.
2430        (PortType::U8, PortType::I16) => Some(Box::new(N::U8ToI16::new())),
2431        (PortType::U8, PortType::I32) => Some(Box::new(N::U8ToI32::new())),
2432        (PortType::U8, PortType::I64) => Some(Box::new(N::U8ToI64::new())),
2433        (PortType::U8, PortType::F32) => Some(Box::new(N::U8ToF32::new())),
2434        (PortType::U16, PortType::I32) => Some(Box::new(N::U16ToI32::new())),
2435        (PortType::U16, PortType::I64) => Some(Box::new(N::U16ToI64::new())),
2436        (PortType::U16, PortType::F32) => Some(Box::new(N::U16ToF32::new())),
2437        (PortType::I8, PortType::F32) => Some(Box::new(N::I8ToF32::new())),
2438        (PortType::I16, PortType::F32) => Some(Box::new(N::I16ToF32::new())),
2439        (PortType::U8, PortType::F16) => Some(Box::new(N::U8ToF16::new())),
2440        (PortType::I8, PortType::F16) => Some(Box::new(N::I8ToF16::new())),
2441        (PortType::U8, PortType::Str) => Some(Box::new(N::U8ToString::new())),
2442        (PortType::U16, PortType::Str) => Some(Box::new(N::U16ToString::new())),
2443        (PortType::I8, PortType::Str) => Some(Box::new(N::I8ToString::new())),
2444        (PortType::I16, PortType::Str) => Some(Box::new(N::I16ToString::new())),
2445        (PortType::F16, PortType::Str) => Some(Box::new(N::F16ToString::new())),
2446        (PortType::Bool, PortType::U8) => Some(Box::new(N::BoolToU8::new())),
2447        (PortType::Bool, PortType::U16) => Some(Box::new(N::BoolToU16::new())),
2448        (PortType::Bool, PortType::I8) => Some(Box::new(N::BoolToI8::new())),
2449        (PortType::Bool, PortType::I16) => Some(Box::new(N::BoolToI16::new())),
2450        (PortType::Bool, PortType::F16) => Some(Box::new(N::BoolToF16::new())),
2451        (PortType::U8, PortType::Bool) => Some(Box::new(N::U8ToBool::new())),
2452        (PortType::U16, PortType::Bool) => Some(Box::new(N::U16ToBool::new())),
2453        (PortType::I8, PortType::Bool) => Some(Box::new(N::I8ToBool::new())),
2454        (PortType::I16, PortType::Bool) => Some(Box::new(N::I16ToBool::new())),
2455        (PortType::F16, PortType::Bool) => Some(Box::new(N::F16ToBool::new())),
2456        (PortType::U8, PortType::Bytes) => Some(Box::new(N::U8ToBytes::new())),
2457        (PortType::U16, PortType::Bytes) => Some(Box::new(N::U16ToBytes::new())),
2458        (PortType::I8, PortType::Bytes) => Some(Box::new(N::I8ToBytes::new())),
2459        (PortType::I16, PortType::Bytes) => Some(Box::new(N::I16ToBytes::new())),
2460        (PortType::F16, PortType::Bytes) => Some(Box::new(N::F16ToBytes::new())),
2461        (PortType::U8, PortType::Json) => Some(Box::new(N::U8ToJson::new())),
2462        (PortType::U16, PortType::Json) => Some(Box::new(N::U16ToJson::new())),
2463        (PortType::I8, PortType::Json) => Some(Box::new(N::I8ToJson::new())),
2464        (PortType::I16, PortType::Json) => Some(Box::new(N::I16ToJson::new())),
2465
2466        // ── 128-bit integers (cranelift I128) ───────────────────
2467        // Widenings from the 64-bit carriers, Display renders,
2468        // LE byte / decimal-string JSON wraps. → f64 mirrors
2469        // u64→f64's class-A treatment (defined for every input).
2470        (PortType::U64, PortType::U128) => Some(Box::new(W::U64ToU128::new())),
2471        (PortType::U64, PortType::I128) => Some(Box::new(W::U64ToI128::new())),
2472        (PortType::I64, PortType::I128) => Some(Box::new(W::I64ToI128::new())),
2473        // Totality fills: every ≤64-bit integer widens losslessly
2474        // into the 128-bit carriers (unsigned → both signednesses,
2475        // signed → i128), `bool` widens to both, and the nonzero
2476        // test `128 → bool` is total. All class A.
2477        (PortType::U8, PortType::U128) => Some(Box::new(W::U8ToU128::new())),
2478        (PortType::U8, PortType::I128) => Some(Box::new(W::U8ToI128::new())),
2479        (PortType::U16, PortType::U128) => Some(Box::new(W::U16ToU128::new())),
2480        (PortType::U16, PortType::I128) => Some(Box::new(W::U16ToI128::new())),
2481        (PortType::U32, PortType::U128) => Some(Box::new(W::U32ToU128::new())),
2482        (PortType::U32, PortType::I128) => Some(Box::new(W::U32ToI128::new())),
2483        (PortType::I8, PortType::I128) => Some(Box::new(W::I8ToI128::new())),
2484        (PortType::I16, PortType::I128) => Some(Box::new(W::I16ToI128::new())),
2485        (PortType::I32, PortType::I128) => Some(Box::new(W::I32ToI128::new())),
2486        (PortType::Bool, PortType::U128) => Some(Box::new(W::BoolToU128::new())),
2487        (PortType::Bool, PortType::I128) => Some(Box::new(W::BoolToI128::new())),
2488        (PortType::U128, PortType::Bool) => Some(Box::new(W::U128ToBool::new())),
2489        (PortType::I128, PortType::Bool) => Some(Box::new(W::I128ToBool::new())),
2490        (PortType::U128, PortType::F64) => Some(Box::new(W::U128ToF64::new())),
2491        (PortType::I128, PortType::F64) => Some(Box::new(W::I128ToF64::new())),
2492        (PortType::U128, PortType::Str) => Some(Box::new(W::U128ToString::new())),
2493        (PortType::I128, PortType::Str) => Some(Box::new(W::I128ToString::new())),
2494        (PortType::U128, PortType::Bytes) => Some(Box::new(W::U128ToBytes::new())),
2495        (PortType::I128, PortType::Bytes) => Some(Box::new(W::I128ToBytes::new())),
2496        (PortType::U128, PortType::Json) => Some(Box::new(W::U128ToJson::new())),
2497        (PortType::I128, PortType::Json) => Some(Box::new(W::I128ToJson::new())),
2498
2499        // ── Register views (free bitcasts) ──────────────────────
2500        // Any reg→reg pair heals with a zero-cost retag — the
2501        // materialized "views are free bitcasts" rule
2502        // (type_system_alignment.md §3).
2503        (from, to) if crate::library::register_view::is_reg_port(from) => {
2504            crate::library::register_view::reg_view(to)
2505        }
2506
2507        // ── Vector lane completion — class A (total) ────────────
2508        // Lossless inter-lane widenings, `→ Bytes` serialise, and
2509        // integer-lane `→ Json`/`→ Str`. See library/polyfill_complete.rs.
2510        (PortType::VecI8, PortType::VecI16) => Some(Box::new(C::VecI8ToVecI16::new())),
2511        (PortType::VecI8, PortType::VecI32) => Some(Box::new(C::VecI8ToVecI32::new())),
2512        (PortType::VecI8, PortType::VecI64) => Some(Box::new(C::VecI8ToVecI64::new())),
2513        (PortType::VecI8, PortType::VecF16) => Some(Box::new(C::VecI8ToVecF16::new())),
2514        (PortType::VecI8, PortType::VecF32) => Some(Box::new(C::VecI8ToVecF32::new())),
2515        (PortType::VecI8, PortType::VecF64) => Some(Box::new(C::VecI8ToVecF64::new())),
2516        (PortType::VecI16, PortType::VecI32) => Some(Box::new(C::VecI16ToVecI32::new())),
2517        (PortType::VecI16, PortType::VecI64) => Some(Box::new(C::VecI16ToVecI64::new())),
2518        (PortType::VecI16, PortType::VecF32) => Some(Box::new(C::VecI16ToVecF32::new())),
2519        (PortType::VecI16, PortType::VecF64) => Some(Box::new(C::VecI16ToVecF64::new())),
2520        (PortType::VecI32, PortType::VecI64) => Some(Box::new(C::VecI32ToVecI64::new())),
2521        (PortType::VecI32, PortType::VecF64) => Some(Box::new(C::VecI32ToVecF64::new())),
2522        (PortType::VecI64, PortType::VecF64) => Some(Box::new(C::VecI64ToVecF64::new())),
2523        (PortType::VecF16, PortType::VecF32) => Some(Box::new(C::VecF16ToVecF32::new())),
2524        (PortType::VecF16, PortType::VecF64) => Some(Box::new(C::VecF16ToVecF64::new())),
2525        (PortType::VecF32, PortType::VecF64) => Some(Box::new(C::VecF32ToVecF64::new())),
2526        (PortType::VecF64, PortType::Bytes) => Some(Box::new(C::VecF64ToBytes::new())),
2527        (PortType::VecI64, PortType::Bytes) => Some(Box::new(C::VecI64ToBytes::new())),
2528        (PortType::VecF16, PortType::Bytes) => Some(Box::new(C::VecF16ToBytes::new())),
2529        (PortType::VecI16, PortType::Bytes) => Some(Box::new(C::VecI16ToBytes::new())),
2530        (PortType::VecI8, PortType::Bytes) => Some(Box::new(C::VecI8ToBytes::new())),
2531        (PortType::VecI64, PortType::Json) => Some(Box::new(C::VecI64ToJson::new())),
2532        (PortType::VecI16, PortType::Json) => Some(Box::new(C::VecI16ToJson::new())),
2533        (PortType::VecI8, PortType::Json) => Some(Box::new(C::VecI8ToJson::new())),
2534        (PortType::VecI32, PortType::Str) => Some(Box::new(P::VecI32ToStr::new())),
2535        (PortType::VecI64, PortType::Str) => Some(Box::new(C::VecI64ToStr::new())),
2536        (PortType::VecI16, PortType::Str) => Some(Box::new(C::VecI16ToStr::new())),
2537        (PortType::VecI8, PortType::Str) => Some(Box::new(C::VecI8ToStr::new())),
2538
2539        _ => None,
2540    }
2541}
2542
2543/// Boundary adapter catalog. Consulted by
2544/// `adapt_boundary_value` when a host-injected scope value
2545/// crosses into a typed slot. Strictly a superset of
2546/// [`auto_adapter`]: every intra-graph adapter is also a
2547/// boundary adapter, plus all the lossy / parseable / shape-
2548/// checking adapters that can panic on input the assembler
2549/// can't statically verify.
2550///
2551/// Boundary-only adapters fall into four classes:
2552///
2553/// - **Numeric narrowings** — `U64→{U32, I64, I32, F32}`,
2554///   `F64→{U64, U32, I64, I32, F32}`, etc. Range-checked,
2555///   panic on out-of-range.
2556/// - **Str → X parsers** — workload-param flow (YAML string
2557///   interpolations, comma-split iter-values). Panic on
2558///   unparseable input.
2559/// - **Bytes → X parsers** — wrong-length panics. Numeric
2560///   reads expect exactly sizeof(N) bytes; Vec reads expect
2561///   a multiple of sizeof(element).
2562/// - **Json → X extractors** — shape mismatch panics
2563///   (`Json::Array` expected for Vec; `Json::Number` for
2564///   numerics; etc.).
2565///
2566/// Plus a small set of "almost-auto" adapters that the
2567/// assembler can't promote because they panic on non-finite
2568/// floats: `F64→Json`, `F32→Json`, `VecF32→Json`,
2569/// `VecF32→Str`.
2570///
2571/// See `polydat/docs/design/type_system.md`.
2572pub fn boundary_adapter(from: PortType, to: PortType) -> Option<Box<dyn PolydatNode>> {
2573    if let Some(adapter) = auto_adapter(from, to) {
2574        return Some(adapter);
2575    }
2576    use crate::library::convert::{StrToBool, StrToF64, StrToU64};
2577    use crate::library::polyfill as P;
2578    use crate::library::polyfill_128 as W;
2579    use crate::library::polyfill_complete as C;
2580    use crate::library::polyfill_narrow as N;
2581    match (from, to) {
2582        // ── Numeric narrowings + non-widening casts ─────────────
2583        (PortType::U64, PortType::U32) => Some(Box::new(P::U64ToU32::new())),
2584        (PortType::U64, PortType::I64) => Some(Box::new(P::U64ToI64::new())),
2585        (PortType::U64, PortType::I32) => Some(Box::new(P::U64ToI32::new())),
2586        (PortType::U64, PortType::F32) => Some(Box::new(P::U64ToF32::new())),
2587        (PortType::U32, PortType::I32) => Some(Box::new(P::U32ToI32::new())),
2588        (PortType::U32, PortType::F32) => Some(Box::new(P::U32ToF32::new())),
2589        (PortType::I64, PortType::U64) => Some(Box::new(P::I64ToU64::new())),
2590        (PortType::I64, PortType::U32) => Some(Box::new(P::I64ToU32::new())),
2591        (PortType::I64, PortType::I32) => Some(Box::new(P::I64ToI32::new())),
2592        (PortType::I64, PortType::F32) => Some(Box::new(P::I64ToF32::new())),
2593        (PortType::I32, PortType::U64) => Some(Box::new(P::I32ToU64::new())),
2594        (PortType::I32, PortType::U32) => Some(Box::new(P::I32ToU32::new())),
2595        (PortType::I32, PortType::F32) => Some(Box::new(P::I32ToF32::new())),
2596        (PortType::F64, PortType::U64) => Some(Box::new(P::F64ToU64Checked::new())),
2597        (PortType::F64, PortType::U32) => Some(Box::new(P::F64ToU32::new())),
2598        (PortType::F64, PortType::I64) => Some(Box::new(P::F64ToI64::new())),
2599        (PortType::F64, PortType::I32) => Some(Box::new(P::F64ToI32::new())),
2600        (PortType::F64, PortType::F32) => Some(Box::new(P::F64ToF32::new())),
2601        (PortType::F32, PortType::U64) => Some(Box::new(P::F32ToU64::new())),
2602        (PortType::F32, PortType::U32) => Some(Box::new(P::F32ToU32::new())),
2603        (PortType::F32, PortType::I64) => Some(Box::new(P::F32ToI64::new())),
2604        (PortType::F32, PortType::I32) => Some(Box::new(P::F32ToI32::new())),
2605
2606        // ── Str → X parsers (boundary-only: panic on unparseable)
2607        (PortType::Str, PortType::Bool) => Some(Box::new(StrToBool::new())),
2608        (PortType::Str, PortType::U64) => Some(Box::new(StrToU64::new())),
2609        (PortType::Str, PortType::F64) => Some(Box::new(StrToF64::new())),
2610        (PortType::Str, PortType::U32) => Some(Box::new(P::StrToU32::new())),
2611        (PortType::Str, PortType::I64) => Some(Box::new(P::StrToI64::new())),
2612        (PortType::Str, PortType::I32) => Some(Box::new(P::StrToI32::new())),
2613        (PortType::Str, PortType::F32) => Some(Box::new(P::StrToF32::new())),
2614        (PortType::Str, PortType::Bytes) => Some(Box::new(P::StrToBytes::new())),
2615        (PortType::Str, PortType::Json) => Some(Box::new(P::StrToJson::new())),
2616        (PortType::Str, PortType::VecF32) => Some(Box::new(P::StrToVecF32::new())),
2617        (PortType::Str, PortType::VecI32) => Some(Box::new(P::StrToVecI32::new())),
2618
2619        // ── Bytes → X (length-checked, little-endian) ───────────
2620        (PortType::Bytes, PortType::U64) => Some(Box::new(P::BytesToU64::new())),
2621        (PortType::Bytes, PortType::U32) => Some(Box::new(P::BytesToU32::new())),
2622        (PortType::Bytes, PortType::I64) => Some(Box::new(P::BytesToI64::new())),
2623        (PortType::Bytes, PortType::I32) => Some(Box::new(P::BytesToI32::new())),
2624        (PortType::Bytes, PortType::F64) => Some(Box::new(P::BytesToF64::new())),
2625        (PortType::Bytes, PortType::F32) => Some(Box::new(P::BytesToF32::new())),
2626        (PortType::Bytes, PortType::Bool) => Some(Box::new(P::BytesToBool::new())),
2627        (PortType::Bytes, PortType::Str) => Some(Box::new(P::BytesToStr::new())),
2628        (PortType::Bytes, PortType::Json) => Some(Box::new(P::BytesToJson::new())),
2629        (PortType::Bytes, PortType::VecF32) => Some(Box::new(P::BytesToVecF32::new())),
2630        (PortType::Bytes, PortType::VecI32) => Some(Box::new(P::BytesToVecI32::new())),
2631
2632        // ── Json → X (shape-checked) ────────────────────────────
2633        (PortType::Json, PortType::U64) => Some(Box::new(P::JsonToU64::new())),
2634        (PortType::Json, PortType::U32) => Some(Box::new(P::JsonToU32::new())),
2635        (PortType::Json, PortType::I64) => Some(Box::new(P::JsonToI64::new())),
2636        (PortType::Json, PortType::I32) => Some(Box::new(P::JsonToI32::new())),
2637        (PortType::Json, PortType::F64) => Some(Box::new(P::JsonToF64::new())),
2638        (PortType::Json, PortType::F32) => Some(Box::new(P::JsonToF32::new())),
2639        (PortType::Json, PortType::Bool) => Some(Box::new(P::JsonToBool::new())),
2640        (PortType::Json, PortType::Bytes) => Some(Box::new(P::JsonToBytes::new())),
2641        (PortType::Json, PortType::VecF32) => Some(Box::new(P::JsonToVecF32::new())),
2642        (PortType::Json, PortType::VecI32) => Some(Box::new(P::JsonToVecI32::new())),
2643
2644        // ── Almost-auto (panic on non-finite floats) ────────────
2645        (PortType::F64, PortType::Json) => Some(Box::new(P::F64ToJson::new())),
2646        (PortType::F32, PortType::Json) => Some(Box::new(P::F32ToJson::new())),
2647        (PortType::VecF32, PortType::Json) => Some(Box::new(P::VecF32ToJson::new())),
2648        (PortType::VecF32, PortType::Str) => Some(Box::new(P::VecF32ToStr::new())),
2649
2650        // ── Vec ↔ Vec (lossy round) ─────────────────────────────
2651        (PortType::VecF32, PortType::VecI32) => Some(Box::new(P::VecF32ToVecI32::new())),
2652
2653        // ── Narrow cranelift widths (u8/i8/u16/i16/f16) ─────────
2654        // Range-checked narrowings + parsers + shape-checked
2655        // extractors, mirroring the u32/i32/f32 rows.
2656        (PortType::U64, PortType::U8) => Some(Box::new(N::U64ToU8::new())),
2657        (PortType::U32, PortType::U8) => Some(Box::new(N::U32ToU8::new())),
2658        (PortType::U16, PortType::U8) => Some(Box::new(N::U16ToU8::new())),
2659        (PortType::I64, PortType::U8) => Some(Box::new(N::I64ToU8::new())),
2660        (PortType::F64, PortType::U8) => Some(Box::new(N::F64ToU8::new())),
2661        (PortType::U64, PortType::U16) => Some(Box::new(N::U64ToU16::new())),
2662        (PortType::U32, PortType::U16) => Some(Box::new(N::U32ToU16::new())),
2663        (PortType::I64, PortType::U16) => Some(Box::new(N::I64ToU16::new())),
2664        (PortType::F64, PortType::U16) => Some(Box::new(N::F64ToU16::new())),
2665        (PortType::I64, PortType::I8) => Some(Box::new(N::I64ToI8::new())),
2666        (PortType::I32, PortType::I8) => Some(Box::new(N::I32ToI8::new())),
2667        (PortType::U64, PortType::I8) => Some(Box::new(N::U64ToI8::new())),
2668        (PortType::F64, PortType::I8) => Some(Box::new(N::F64ToI8::new())),
2669        (PortType::I64, PortType::I16) => Some(Box::new(N::I64ToI16::new())),
2670        (PortType::I32, PortType::I16) => Some(Box::new(N::I32ToI16::new())),
2671        (PortType::U64, PortType::I16) => Some(Box::new(N::U64ToI16::new())),
2672        (PortType::F64, PortType::I16) => Some(Box::new(N::F64ToI16::new())),
2673        (PortType::F64, PortType::F16) => Some(Box::new(N::F64ToF16::new())),
2674        (PortType::F32, PortType::F16) => Some(Box::new(N::F32ToF16::new())),
2675        (PortType::U64, PortType::F16) => Some(Box::new(N::U64ToF16::new())),
2676        (PortType::Str, PortType::U8) => Some(Box::new(N::StrToU8::new())),
2677        (PortType::Str, PortType::U16) => Some(Box::new(N::StrToU16::new())),
2678        (PortType::Str, PortType::I8) => Some(Box::new(N::StrToI8::new())),
2679        (PortType::Str, PortType::I16) => Some(Box::new(N::StrToI16::new())),
2680        (PortType::Str, PortType::F16) => Some(Box::new(N::StrToF16::new())),
2681        (PortType::Bytes, PortType::U8) => Some(Box::new(N::BytesToU8::new())),
2682        (PortType::Bytes, PortType::U16) => Some(Box::new(N::BytesToU16::new())),
2683        (PortType::Bytes, PortType::I8) => Some(Box::new(N::BytesToI8::new())),
2684        (PortType::Bytes, PortType::I16) => Some(Box::new(N::BytesToI16::new())),
2685        (PortType::Bytes, PortType::F16) => Some(Box::new(N::BytesToF16::new())),
2686        (PortType::Json, PortType::U8) => Some(Box::new(N::JsonToU8::new())),
2687        (PortType::Json, PortType::U16) => Some(Box::new(N::JsonToU16::new())),
2688        (PortType::Json, PortType::I8) => Some(Box::new(N::JsonToI8::new())),
2689        (PortType::Json, PortType::I16) => Some(Box::new(N::JsonToI16::new())),
2690        (PortType::Json, PortType::F16) => Some(Box::new(N::JsonToF16::new())),
2691        // f16 → Json panics on non-finite (same as f32 → Json).
2692        (PortType::F16, PortType::Json) => Some(Box::new(N::F16ToJson::new())),
2693
2694        // ── 128-bit integers (range-checked / parse / shape) ────
2695        (PortType::U128, PortType::U64) => Some(Box::new(W::U128ToU64::new())),
2696        (PortType::I128, PortType::I64) => Some(Box::new(W::I128ToI64::new())),
2697        (PortType::I64, PortType::U128) => Some(Box::new(W::I64ToU128::new())),
2698        (PortType::U128, PortType::I128) => Some(Box::new(W::U128ToI128::new())),
2699        (PortType::I128, PortType::U128) => Some(Box::new(W::I128ToU128::new())),
2700        (PortType::F64, PortType::U128) => Some(Box::new(W::F64ToU128::new())),
2701        (PortType::F64, PortType::I128) => Some(Box::new(W::F64ToI128::new())),
2702        (PortType::Str, PortType::U128) => Some(Box::new(W::StrToU128::new())),
2703        (PortType::Str, PortType::I128) => Some(Box::new(W::StrToI128::new())),
2704        (PortType::Bytes, PortType::U128) => Some(Box::new(W::BytesToU128::new())),
2705        (PortType::Bytes, PortType::I128) => Some(Box::new(W::BytesToI128::new())),
2706        (PortType::Json, PortType::U128) => Some(Box::new(W::JsonToU128::new())),
2707        (PortType::Json, PortType::I128) => Some(Box::new(W::JsonToI128::new())),
2708
2709        // ── Scalar matrix completion (library/polyfill_complete.rs) ──
2710        // Every remaining scalar→scalar narrowing / cross-sign /
2711        // float→int / int→narrow-float cell, so the 14×14 scalar
2712        // block has no `·`. All class B (range-checked, can panic).
2713        (PortType::U8, PortType::I8) => Some(Box::new(C::U8ToI8::new())),
2714        (PortType::I8, PortType::U8) => Some(Box::new(C::I8ToU8::new())),
2715        (PortType::I8, PortType::U16) => Some(Box::new(C::I8ToU16::new())),
2716        (PortType::I8, PortType::U32) => Some(Box::new(C::I8ToU32::new())),
2717        (PortType::I8, PortType::U64) => Some(Box::new(C::I8ToU64::new())),
2718        (PortType::I8, PortType::U128) => Some(Box::new(C::I8ToU128::new())),
2719        (PortType::U16, PortType::I8) => Some(Box::new(C::U16ToI8::new())),
2720        (PortType::U16, PortType::I16) => Some(Box::new(C::U16ToI16::new())),
2721        (PortType::U16, PortType::F16) => Some(Box::new(C::U16ToF16::new())),
2722        (PortType::I16, PortType::U8) => Some(Box::new(C::I16ToU8::new())),
2723        (PortType::I16, PortType::I8) => Some(Box::new(C::I16ToI8::new())),
2724        (PortType::I16, PortType::U16) => Some(Box::new(C::I16ToU16::new())),
2725        (PortType::I16, PortType::F16) => Some(Box::new(C::I16ToF16::new())),
2726        (PortType::I16, PortType::U32) => Some(Box::new(C::I16ToU32::new())),
2727        (PortType::I16, PortType::U64) => Some(Box::new(C::I16ToU64::new())),
2728        (PortType::I16, PortType::U128) => Some(Box::new(C::I16ToU128::new())),
2729        (PortType::U32, PortType::I8) => Some(Box::new(C::U32ToI8::new())),
2730        (PortType::U32, PortType::I16) => Some(Box::new(C::U32ToI16::new())),
2731        (PortType::U32, PortType::F16) => Some(Box::new(C::U32ToF16::new())),
2732        (PortType::I32, PortType::U8) => Some(Box::new(C::I32ToU8::new())),
2733        (PortType::I32, PortType::U16) => Some(Box::new(C::I32ToU16::new())),
2734        (PortType::I32, PortType::F16) => Some(Box::new(C::I32ToF16::new())),
2735        (PortType::I32, PortType::U128) => Some(Box::new(C::I32ToU128::new())),
2736        (PortType::F16, PortType::U8) => Some(Box::new(C::F16ToU8::new())),
2737        (PortType::F16, PortType::I8) => Some(Box::new(C::F16ToI8::new())),
2738        (PortType::F16, PortType::U16) => Some(Box::new(C::F16ToU16::new())),
2739        (PortType::F16, PortType::I16) => Some(Box::new(C::F16ToI16::new())),
2740        (PortType::F16, PortType::U32) => Some(Box::new(C::F16ToU32::new())),
2741        (PortType::F16, PortType::I32) => Some(Box::new(C::F16ToI32::new())),
2742        (PortType::F16, PortType::U64) => Some(Box::new(C::F16ToU64::new())),
2743        (PortType::F16, PortType::I64) => Some(Box::new(C::F16ToI64::new())),
2744        (PortType::F16, PortType::U128) => Some(Box::new(C::F16ToU128::new())),
2745        (PortType::F16, PortType::I128) => Some(Box::new(C::F16ToI128::new())),
2746        (PortType::F32, PortType::U8) => Some(Box::new(C::F32ToU8::new())),
2747        (PortType::F32, PortType::I8) => Some(Box::new(C::F32ToI8::new())),
2748        (PortType::F32, PortType::U16) => Some(Box::new(C::F32ToU16::new())),
2749        (PortType::F32, PortType::I16) => Some(Box::new(C::F32ToI16::new())),
2750        (PortType::F32, PortType::U128) => Some(Box::new(C::F32ToU128::new())),
2751        (PortType::F32, PortType::I128) => Some(Box::new(C::F32ToI128::new())),
2752        (PortType::I64, PortType::F16) => Some(Box::new(C::I64ToF16::new())),
2753        (PortType::U128, PortType::U8) => Some(Box::new(C::U128ToU8::new())),
2754        (PortType::U128, PortType::I8) => Some(Box::new(C::U128ToI8::new())),
2755        (PortType::U128, PortType::U16) => Some(Box::new(C::U128ToU16::new())),
2756        (PortType::U128, PortType::I16) => Some(Box::new(C::U128ToI16::new())),
2757        (PortType::U128, PortType::F16) => Some(Box::new(C::U128ToF16::new())),
2758        (PortType::U128, PortType::U32) => Some(Box::new(C::U128ToU32::new())),
2759        (PortType::U128, PortType::I32) => Some(Box::new(C::U128ToI32::new())),
2760        (PortType::U128, PortType::F32) => Some(Box::new(C::U128ToF32::new())),
2761        (PortType::U128, PortType::I64) => Some(Box::new(C::U128ToI64::new())),
2762        (PortType::I128, PortType::U8) => Some(Box::new(C::I128ToU8::new())),
2763        (PortType::I128, PortType::I8) => Some(Box::new(C::I128ToI8::new())),
2764        (PortType::I128, PortType::U16) => Some(Box::new(C::I128ToU16::new())),
2765        (PortType::I128, PortType::I16) => Some(Box::new(C::I128ToI16::new())),
2766        (PortType::I128, PortType::F16) => Some(Box::new(C::I128ToF16::new())),
2767        (PortType::I128, PortType::U32) => Some(Box::new(C::I128ToU32::new())),
2768        (PortType::I128, PortType::I32) => Some(Box::new(C::I128ToI32::new())),
2769        (PortType::I128, PortType::F32) => Some(Box::new(C::I128ToF32::new())),
2770        (PortType::I128, PortType::U64) => Some(Box::new(C::I128ToU64::new())),
2771
2772        // ── Vector lane completion — class B (lossy / checked) ──
2773        // Inter-lane narrowing + float→int, Bytes/Json/Str decode &
2774        // parse, float-lane → Json/Str (non-finite panics).
2775        (PortType::VecI16, PortType::VecI8) => Some(Box::new(C::VecI16ToVecI8::new())),
2776        (PortType::VecI16, PortType::VecF16) => Some(Box::new(C::VecI16ToVecF16::new())),
2777        (PortType::VecI32, PortType::VecI8) => Some(Box::new(C::VecI32ToVecI8::new())),
2778        (PortType::VecI32, PortType::VecI16) => Some(Box::new(C::VecI32ToVecI16::new())),
2779        (PortType::VecI32, PortType::VecF16) => Some(Box::new(C::VecI32ToVecF16::new())),
2780        (PortType::VecI64, PortType::VecI8) => Some(Box::new(C::VecI64ToVecI8::new())),
2781        (PortType::VecI64, PortType::VecI16) => Some(Box::new(C::VecI64ToVecI16::new())),
2782        (PortType::VecI64, PortType::VecI32) => Some(Box::new(C::VecI64ToVecI32::new())),
2783        (PortType::VecI64, PortType::VecF16) => Some(Box::new(C::VecI64ToVecF16::new())),
2784        (PortType::VecI64, PortType::VecF32) => Some(Box::new(C::VecI64ToVecF32::new())),
2785        (PortType::VecF16, PortType::VecI8) => Some(Box::new(C::VecF16ToVecI8::new())),
2786        (PortType::VecF16, PortType::VecI16) => Some(Box::new(C::VecF16ToVecI16::new())),
2787        (PortType::VecF16, PortType::VecI32) => Some(Box::new(C::VecF16ToVecI32::new())),
2788        (PortType::VecF16, PortType::VecI64) => Some(Box::new(C::VecF16ToVecI64::new())),
2789        (PortType::VecF32, PortType::VecI8) => Some(Box::new(C::VecF32ToVecI8::new())),
2790        (PortType::VecF32, PortType::VecI16) => Some(Box::new(C::VecF32ToVecI16::new())),
2791        (PortType::VecF32, PortType::VecI64) => Some(Box::new(C::VecF32ToVecI64::new())),
2792        (PortType::VecF32, PortType::VecF16) => Some(Box::new(C::VecF32ToVecF16::new())),
2793        (PortType::VecF64, PortType::VecI8) => Some(Box::new(C::VecF64ToVecI8::new())),
2794        (PortType::VecF64, PortType::VecI16) => Some(Box::new(C::VecF64ToVecI16::new())),
2795        (PortType::VecF64, PortType::VecI32) => Some(Box::new(C::VecF64ToVecI32::new())),
2796        (PortType::VecF64, PortType::VecI64) => Some(Box::new(C::VecF64ToVecI64::new())),
2797        (PortType::VecF64, PortType::VecF16) => Some(Box::new(C::VecF64ToVecF16::new())),
2798        (PortType::VecF64, PortType::VecF32) => Some(Box::new(C::VecF64ToVecF32::new())),
2799        (PortType::Bytes, PortType::VecF64) => Some(Box::new(C::BytesToVecF64::new())),
2800        (PortType::Bytes, PortType::VecI64) => Some(Box::new(C::BytesToVecI64::new())),
2801        (PortType::Bytes, PortType::VecF16) => Some(Box::new(C::BytesToVecF16::new())),
2802        (PortType::Bytes, PortType::VecI16) => Some(Box::new(C::BytesToVecI16::new())),
2803        (PortType::Bytes, PortType::VecI8) => Some(Box::new(C::BytesToVecI8::new())),
2804        (PortType::VecF64, PortType::Json) => Some(Box::new(C::VecF64ToJson::new())),
2805        (PortType::VecF16, PortType::Json) => Some(Box::new(C::VecF16ToJson::new())),
2806        (PortType::Json, PortType::VecF64) => Some(Box::new(C::JsonToVecF64::new())),
2807        (PortType::Json, PortType::VecI64) => Some(Box::new(C::JsonToVecI64::new())),
2808        (PortType::Json, PortType::VecF16) => Some(Box::new(C::JsonToVecF16::new())),
2809        (PortType::Json, PortType::VecI16) => Some(Box::new(C::JsonToVecI16::new())),
2810        (PortType::Json, PortType::VecI8) => Some(Box::new(C::JsonToVecI8::new())),
2811        (PortType::VecF64, PortType::Str) => Some(Box::new(C::VecF64ToStr::new())),
2812        (PortType::VecF16, PortType::Str) => Some(Box::new(C::VecF16ToStr::new())),
2813        (PortType::Str, PortType::VecF64) => Some(Box::new(C::StrToVecF64::new())),
2814        (PortType::Str, PortType::VecI64) => Some(Box::new(C::StrToVecI64::new())),
2815        (PortType::Str, PortType::VecF16) => Some(Box::new(C::StrToVecF16::new())),
2816        (PortType::Str, PortType::VecI16) => Some(Box::new(C::StrToVecI16::new())),
2817        (PortType::Str, PortType::VecI8) => Some(Box::new(C::StrToVecI8::new())),
2818
2819        _ => None,
2820    }
2821}
2822
2823// ── The one constructor (engines.md §3.5) ─────────────────
2824
2825use crate::compile::select::{Engine, KernelError, Provenance};
2826use crate::kernel::Kernel;
2827
2828/// The outputs whose value is fixed for a kernel's life: those a const
2829/// binding declares, and those whose producing node is compile-constant.
2830fn fixed_outputs(resolved: &ResolvedDag) -> std::collections::HashSet<String> {
2831    let classes = PolydatProgram::classify_lifecycle(
2832        &resolved.nodes,
2833        &resolved.wiring,
2834        &resolved.input_defs,
2835        &resolved.output_map,
2836        &resolved.output_modifiers,
2837    );
2838    resolved
2839        .output_map
2840        .iter()
2841        .filter(|(name, (node, _))| {
2842            resolved.const_outputs.contains(*name)
2843                || classes.lifecycle[*node] == crate::kernel::EvalLifecycle::CompileConst
2844        })
2845        .map(|(name, _)| name.clone())
2846        .collect()
2847}
2848
2849impl PolydatAssembler {
2850    /// Build a kernel on `engine`: the interpreter, the closure tier,
2851    /// the hybrid kernel, or pure native code, with the provenance mode
2852    /// the engine names. Every engine accepts every program the
2853    /// interpreter accepts, or refuses it with a reason naming the node
2854    /// or construct ([`KernelError::Refused`]). The older constructors
2855    /// (`compile`, `try_compile*`, `compile_hybrid`)
2856    /// remain as aliases of this one for their engine.
2857    pub fn compile_with(self, engine: Engine) -> Result<Box<dyn Kernel>, KernelError> {
2858        self.compile_engine_with_log(engine, None)
2859    }
2860
2861    /// [`Self::compile_with`] on [`Engine::default`]: compiled code, with
2862    /// the JIT where the build has it.
2863    pub fn compile_kernel(self) -> Result<Box<dyn Kernel>, KernelError> {
2864        self.compile_with(Engine::default())
2865    }
2866
2867    /// [`Self::compile_with`] with the compile event log, which
2868    /// receives the assembly events for every engine.
2869    pub fn compile_engine_with_log(
2870        self,
2871        engine: Engine,
2872        log: Option<&mut crate::dsl::events::CompileEventLog>,
2873    ) -> Result<Box<dyn Kernel>, KernelError> {
2874        match engine {
2875            // The one engine with no slot surface, and so the one this
2876            // function builds itself.
2877            Engine::Interpreter(cones) => {
2878                let mut asm = self;
2879                asm.jit_mode = Some(cones);
2880                Ok(Box::new(asm.compile_with_log(log)?))
2881            }
2882            // Every compiled engine is built once, by
2883            // `compile_slots_with_log`, and upcast for the caller who
2884            // asked for the ordinary surface. One builder, two views.
2885            _ => Ok(self.compile_slots_with_log(engine, log)?),
2886        }
2887    }
2888
2889    /// Build on `engine` and keep the slot surface: the same kernel
2890    /// [`Self::compile_with`] builds, typed as
2891    /// [`SlotKernel`](crate::compile::SlotKernel) so a caller can read
2892    /// a buffer slot and evaluate one without boxing a `Value`.
2893    ///
2894    /// For testing, measurement and diagnostics, where the layout is
2895    /// the subject. Normative use is `compile_with`, which returns the
2896    /// same kernel as `Box<dyn Kernel>`; a `Box<dyn SlotKernel>`
2897    /// upcasts to one wherever the ordinary surface will do, so a
2898    /// caller that wants both needs only this call.
2899    ///
2900    /// `Engine::Interpreter` is refused, and cannot be anything else:
2901    /// the interpreter holds typed `Value` buffers and has no slot to
2902    /// name. Ask for a compiled engine, or use `compile_with` and the
2903    /// `Kernel` trait, which every engine answers.
2904    pub fn compile_slots(
2905        self,
2906        engine: Engine,
2907    ) -> Result<Box<dyn crate::compile::SlotKernel>, KernelError> {
2908        self.compile_slots_with_log(engine, None)
2909    }
2910
2911    /// [`Self::compile_slots`] with the compile event log. The kernel is
2912    /// initialized before it is returned: its consts are evaluated.
2913    pub fn compile_slots_with_log(
2914        self,
2915        engine: Engine,
2916        log: Option<&mut crate::dsl::events::CompileEventLog>,
2917    ) -> Result<Box<dyn crate::compile::SlotKernel>, KernelError> {
2918        let template = self.template;
2919        let mut kernel = self.build_slots_with_log(engine, log)?;
2920        if !template {
2921            crate::kernel::Kernel::init(kernel.as_mut())?;
2922        }
2923        Ok(kernel)
2924    }
2925
2926    /// The compiled kernel on `engine`, not yet initialized.
2927    fn build_slots_with_log(
2928        self,
2929        engine: Engine,
2930        mut log: Option<&mut crate::dsl::events::CompileEventLog>,
2931    ) -> Result<Box<dyn crate::compile::SlotKernel>, KernelError> {
2932        let refused = |reason: String| KernelError::Refused { engine, reason };
2933        // A builder names its tier but not the provenance mode it was
2934        // asked for, which the caller is entitled to see back. Only a
2935        // refusal is restamped: a fold failure belongs to the program
2936        // and names no engine at all.
2937        let asked = |e: KernelError| match e {
2938            KernelError::Refused { reason, .. } => KernelError::Refused { engine, reason },
2939            other => other,
2940        };
2941        let strict = self.strict;
2942        match engine {
2943            Engine::Interpreter(_) => Err(refused(
2944                "the interpreter has no slot buffer: its buffers are typed `Value`s, so \
2945                 there is no slot to name. Ask for `closures`, `native` or `pure-native` \
2946                 for the slot surface, or compile with `compile_with` and drive the \
2947                 kernel through the `Kernel` trait, which every engine answers."
2948                    .into(),
2949            )),
2950            Engine::Closures(prov) => {
2951                let resolved = self.resolve_with_log(log.as_deref_mut())?;
2952                if strict {
2953                    Self::refuse_strict(&resolved)?;
2954                }
2955                let folded = log.is_some().then(|| Self::constant_sites(&resolved));
2956                let (node_total, output_total) =
2957                    (resolved.nodes.len(), resolved.output_order.len());
2958                let kernel = Self::closures_from(resolved, prov).map_err(asked)?;
2959                Self::log_folded(kernel.as_ref(), folded, log.as_deref_mut());
2960                Self::log_summary(log, node_total, output_total);
2961                Ok(kernel)
2962            }
2963            // Available in every build. Without the `jit` feature this
2964            // engine's kernel has no native segment in it and every
2965            // step is a closure, which `plan()` reports as it reports
2966            // any other mix; the engine is the kernel architecture, and
2967            // how much of it got native code is the plan. Refusing here
2968            // would take a working tier away from an architecture that
2969            // has no code generator, which is the one place it is most
2970            // worth keeping every engine that can be built.
2971            Engine::Native(prov) => {
2972                {
2973                    let resolved = self.resolve_with_log(log.as_deref_mut())?;
2974                    if strict {
2975                        Self::refuse_strict(&resolved)?;
2976                    }
2977                    let folded = log.is_some().then(|| Self::constant_sites(&resolved));
2978                    let (node_total, output_total) =
2979                        (resolved.nodes.len(), resolved.output_order.len());
2980                    // Push without the cone guard has no native kernel
2981                    // (engines.md §4), so a request for it cannot be
2982                    // realized. Refuse it rather than build push-pull and
2983                    // report a mode the caller did not ask for: a kernel's
2984                    // reported configuration is the one it runs.
2985                    if prov == Provenance::Push {
2986                        return Err(refused(
2987                            "native code has no push-only kernel: push-side invalidation \
2988                             without the cone guard has no native form. Ask for `pushpull` \
2989                             for both, `pull` for the guard alone, or `auto` to let the \
2990                             selector choose; `push` alone is available on the closure tier."
2991                                .into(),
2992                        ));
2993                    }
2994                    let prov = Self::provenance_for(prov, &resolved);
2995                    let kernel = Self::hybrid_from(resolved).map_err(asked)?;
2996                    let kernel: Box<dyn crate::compile::SlotKernel> = match prov {
2997                        Provenance::Raw => Box::new(kernel.into_raw()),
2998                        Provenance::Pull => Box::new(kernel.into_pull()),
2999                        // `provenance_for` resolves `Auto` to `Raw`,
3000                        // `Pull`, or `PushPull`, and `Push` was refused
3001                        // above, so this arm is `PushPull` in practice.
3002                        // It refuses rather than panics if the selector
3003                        // ever gains a mode with no native kernel.
3004                        Provenance::PushPull | Provenance::Auto => Box::new(kernel),
3005                        Provenance::Push => {
3006                            return Err(refused("native code has no push-only kernel".into()));
3007                        }
3008                    };
3009                    Self::log_folded(kernel.as_ref(), folded, log.as_deref_mut());
3010                    Self::log_summary(log, node_total, output_total);
3011                    Ok(kernel)
3012                }
3013            }
3014            Engine::PureNative(prov) => {
3015                #[cfg(feature = "jit")]
3016                {
3017                    let resolved = self.resolve_with_log(log.as_deref_mut())?;
3018                    if strict {
3019                        Self::refuse_strict(&resolved)?;
3020                    }
3021                    let folded = log.is_some().then(|| Self::constant_sites(&resolved));
3022                    let (node_total, output_total) =
3023                        (resolved.nodes.len(), resolved.output_order.len());
3024                    // Only raw and push+pull have a pure kernel. A named
3025                    // mode with none is refused rather than silently
3026                    // answered with another, because a kernel reports
3027                    // the configuration it runs; `Auto` delegated the
3028                    // choice, so the selector's pull resolves to
3029                    // push+pull, whose guard subsumes it.
3030                    let prov = match prov {
3031                        Provenance::Auto => match Self::provenance_for(prov, &resolved) {
3032                            Provenance::Raw => Provenance::Raw,
3033                            _ => Provenance::PushPull,
3034                        },
3035                        named @ (Provenance::Raw | Provenance::PushPull) => named,
3036                        other => {
3037                            return Err(refused(format!(
3038                                "pure native code has no {} kernel: the tier keeps only the \
3039                                 two forms the differential needs. Ask for `raw` or \
3040                                 `pushpull`, or `auto` to let the selector choose; every \
3041                                 mode is available on `native`.",
3042                                format!("{other:?}").to_lowercase(),
3043                            )));
3044                        }
3045                    };
3046                    let kernel: Box<dyn crate::compile::SlotKernel> = match prov {
3047                        Provenance::Raw => Box::new(Self::jit_raw_from(resolved).map_err(asked)?),
3048                        _ => Box::new(Self::jit_push_pull_from(resolved).map_err(asked)?),
3049                    };
3050                    Self::log_folded(kernel.as_ref(), folded, log.as_deref_mut());
3051                    Self::log_summary(log, node_total, output_total);
3052                    Ok(kernel)
3053                }
3054                #[cfg(not(feature = "jit"))]
3055                {
3056                    let _ = (prov, log);
3057                    Err(refused(
3058                        "this build has no native code (the `jit` feature is off)".into(),
3059                    ))
3060                }
3061            }
3062        }
3063    }
3064
3065    /// The nodes the compile-constant fold applies to, as the
3066    /// interpreter's fold selects them: no input reaches the node and it
3067    /// has one output; with the slot and type to read once the kernel is
3068    /// built.
3069    fn constant_sites(resolved: &ResolvedDag) -> Vec<(String, usize, crate::ast::PortType)> {
3070        let classes = PolydatProgram::classify_lifecycle(
3071            &resolved.nodes,
3072            &resolved.wiring,
3073            &resolved.input_defs,
3074            &resolved.output_map,
3075            &resolved.output_modifiers,
3076        );
3077        let layout = slot_layout(resolved);
3078        resolved
3079            .nodes
3080            .iter()
3081            .enumerate()
3082            .filter(|(i, n)| {
3083                classes.lifecycle[*i] == crate::kernel::EvalLifecycle::CompileConst
3084                    && n.meta().outs.len() == 1
3085            })
3086            .map(|(i, n)| {
3087                (
3088                    n.meta().name.clone(),
3089                    layout.port_offsets[i][0],
3090                    n.meta().outs[0].typ,
3091                )
3092            })
3093            .collect()
3094    }
3095
3096    /// Record the constants the build folded, as the interpreter's fold
3097    /// records its own: one event per node, with the value it holds.
3098    fn log_folded(
3099        kernel: &dyn Kernel,
3100        sites: Option<Vec<(String, usize, crate::ast::PortType)>>,
3101        log: Option<&mut crate::dsl::events::CompileEventLog>,
3102    ) {
3103        let (Some(sites), Some(log)) = (sites, log) else {
3104            return;
3105        };
3106        for (node, slot, ty) in sites {
3107            let value = crate::kernel::KernelInternals::slot_value(kernel, slot, ty);
3108            if !matches!(value, crate::ast::Value::None) {
3109                log.push(crate::dsl::events::CompileEvent::ConstantFolded {
3110                    node,
3111                    value: value.to_display_string(),
3112                });
3113            }
3114        }
3115    }
3116
3117    /// The provenance mode a compiled engine builds for `prov`: `Auto`
3118    /// is the selector's choice from the resolved graph's shape
3119    /// ([`select::select_prov_mode`]), on the closure tier and the
3120    /// native engine alike; a named mode is taken as given.
3121    fn provenance_for(prov: Provenance, resolved: &ResolvedDag) -> Provenance {
3122        match prov {
3123            Provenance::Auto => {
3124                let analysis =
3125                    select::analyze_graph(&resolved.nodes, &resolved.wiring, &resolved.output_map);
3126                match select::select_prov_mode(&analysis) {
3127                    ProvMode::Raw => Provenance::Raw,
3128                    ProvMode::Pull => Provenance::Pull,
3129                    ProvMode::PushPull => Provenance::PushPull,
3130                }
3131            }
3132            p => p,
3133        }
3134    }
3135
3136    /// The closure-tier kernel of a resolved graph in one provenance
3137    /// mode, or why the closure tier refuses the graph.
3138    fn closures_from(
3139        resolved: ResolvedDag,
3140        prov: Provenance,
3141    ) -> Result<Box<dyn crate::compile::SlotKernel>, KernelError> {
3142        let prov = Self::provenance_for(prov, &resolved);
3143        let (coord_count, total_slots, steps, output_map, ref_slots, extras) =
3144            Self::build_p2_layout(&resolved).map_err(Self::refused_by_closures)?;
3145        let dependents = || {
3146            slot_layout(&resolved).expand_dependents(
3147                &resolved,
3148                &PolydatProgram::compute_dependents(
3149                    &PolydatProgram::compute_provenance(&resolved.nodes, &resolved.wiring),
3150                    resolved.input_defs.len(),
3151                ),
3152            )
3153        };
3154        Ok(match prov {
3155            Provenance::Raw => Box::new(CompiledKernelRaw::new(
3156                coord_count,
3157                total_slots,
3158                steps,
3159                output_map,
3160                ref_slots,
3161                extras,
3162            )?),
3163            Provenance::Push => Box::new(CompiledKernelPush::new(
3164                coord_count,
3165                total_slots,
3166                steps,
3167                output_map,
3168                dependents(),
3169                ref_slots,
3170                extras,
3171            )?),
3172            Provenance::Pull => Box::new(CompiledKernelPull::new(
3173                coord_count,
3174                total_slots,
3175                steps,
3176                output_map,
3177                &dependents(),
3178                ref_slots,
3179                extras,
3180            )?),
3181            Provenance::PushPull | Provenance::Auto => Box::new(CompiledKernelPushPull::new(
3182                coord_count,
3183                total_slots,
3184                steps,
3185                output_map,
3186                dependents(),
3187                ref_slots,
3188                extras,
3189            )?),
3190        })
3191    }
3192}