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