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