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