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