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