Skip to main content

polydat_derive/
lib.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! `polydat-derive` — proc-macro implementation of
5//! [`#[polydat_node]`](polydat_node).
6//!
7//! The attribute turns a typed free function into a polydat node.
8//! From one `fn` it emits the node struct (named after the function
9//! in PascalCase), its `new()` constructor, the `PolydatNode` impl
10//! (`meta`, `eval`, and the compiled forms the signature allows),
11//! and a link-time `NodeRegistration` carrying the `FuncSig` the
12//! DSL registry serves. The function's `///` comment becomes the
13//! struct's documentation and the signature's `description` (first
14//! paragraph) and `help` (the rest).
15//!
16//! ## Arguments
17//!
18//! Each argument is classified by its type and attributes:
19//!
20//! - **Wire** — a per-cycle input. Scalars (`u64`, `i64`, `f64`,
21//!   `bool`, the narrower ints, `f32`, `f16`, `u128`, `i128`),
22//!   strings (`&str`, `String`, `Arc<str>`), bytes (`&[u8]`,
23//!   `Vec<u8>`, `Arc<[u8]>`), JSON (`&serde_json::Value`,
24//!   `Arc<serde_json::Value>`), typed vectors (`&[f32]`, `Vec<i64>`,
25//!   ...), SIMD registers (`Bits128`, `[i32; 4]`, ...), `Arc<T>`
26//!   handles, and host `Ext` types. `Option<T>` marks an input that
27//!   may be unset; `Config<T>` marks a configuration-cost wire.
28//!   `#[constraint(Variant)]` attaches a `ConstConstraint` to a
29//!   wire input.
30//! - **PolyWire** — a `Value` argument: any runtime type; the output
31//!   type of a `Value` return tracks the first PolyWire input.
32//! - **Variadic** — a `&[T]` argument for `T` in `u64`, `bool`,
33//!   `&str`, `String`, `Value`; two consecutive slices form a
34//!   split-halves shape. `variadic_min` and `identity` describe the
35//!   arity.
36//! - **Const** — `Const<u64 | f64 | bool | &str>`, a workload
37//!   constant captured at construction; `#[poly_default(EXPR)]`
38//!   supplies its default. `Const<Vec<C>>` (last) captures every
39//!   trailing constant of the call.
40//! - **Setup** — a `&T` argument with
41//!   `#[poly_const(setup_fn, from = source)]`: derived state
42//!   computed once in `new()` from the named const arguments
43//!   (`from = ()` for none, `from = (a, b)` for several). `T`
44//!   implements `PolydatSetup`.
45//!
46//! ## Returns
47//!
48//! A single wire type; a tuple of wire types (multi-output, named
49//! by `output_names(...)`); `Value` (polymorphic); `Result<T, E>`
50//! for a body that runs once at construction and caches its value;
51//! or a dynamic-output list over a `Const<Vec<C>>` argument.
52//!
53//! ## Attribute parameters
54//!
55//! - `category = <FuncCategory>` — required.
56//! - `struct_name = <Ident>` — the Rust name of the node struct.
57//! - `compiled_u64 = <path>` — `fn(&Node) -> CompiledU64Op`,
58//!   replacing the macro's u64-buffer closure.
59//! - `compiled_slot = <path>` — `fn(&Node, &[PortType]) ->
60//!   CompiledSlotKit`, replacing the slot kit's closure.
61//! - `state = <path>` — per-state scratch: `scratch_layout` and
62//!   `eval_in` delegate to `<path>::layout` / `<path>::eval`.
63//! - `jit_constants = <path>` — `fn(&Node) -> Vec<u64>`.
64//! - `decompose = <path>` — `fn(&Node) -> DecomposedGraph`, emitting
65//!   `impl FusedNode`.
66//! - `simd = "<node>"`, `simd_total` — an exact register-typed
67//!   implementation of the scalar function.
68//! - `purity = <Purity>`, `identity = <expr>`,
69//!   `commutativity = <Commutativity>`, `variadic_min = <int>`.
70//! - `output_names(a, b, ...)` — the ports of a tuple return.
71
72use proc_macro::TokenStream;
73use proc_macro2::TokenStream as TokenStream2;
74use quote::{format_ident, quote};
75use syn::{
76    FnArg, Ident, ItemFn, Meta, Pat, ReturnType, Token, Type, parse::Parser, parse_macro_input,
77    punctuated::Punctuated,
78};
79
80/// `#[polydat_node]` — derive a polydat node from a typed Rust
81/// function signature.
82///
83/// See the crate docs for the argument and return shapes the
84/// macro accepts.
85///
86/// ## Attribute parameters
87///
88/// - `category = <ident>` — the polydat `FuncCategory` variant
89///   the node belongs to (`Comparison`, `Math`, `String`, etc.).
90///   Required; there is no default.
91/// - `struct_name = <Ident>` — the Rust name of the generated node
92///   struct. Defaults to the function name in PascalCase, so a node
93///   `fn geo_cell` produces `struct GeoCell`; set this when that name
94///   is already taken, typically by the value type the node returns.
95///   The DSL name is always the function name.
96/// - `simd = "<node-name>"` declares an exact, lane-independent
97///   register-typed implementation of the scalar function.
98/// - `simd_total` certifies that the declared SIMD implementation is defined
99///   for the complete scalar input domain. It requires `simd`.
100#[proc_macro_attribute]
101pub fn polydat_node(attr: TokenStream, item: TokenStream) -> TokenStream {
102    let func = parse_macro_input!(item as ItemFn);
103
104    let attrs = match parse_attrs(attr.into()) {
105        Ok(a) => a,
106        Err(e) => return e.to_compile_error().into(),
107    };
108
109    match generate(func, attrs) {
110        Ok(ts) => ts.into(),
111        Err(e) => e.to_compile_error().into(),
112    }
113}
114
115/// Parsed `#[polydat_node(...)]` attribute parameters.
116struct NodeAttrs {
117    /// `FuncCategory` variant name — required (no default).
118    /// Forcing the operator to declare the category keeps the
119    /// `describe` / help / categorization surface coherent.
120    category: Ident,
121    /// SRD-80 PR B.7 — override path for `compiled_u64()`. When
122    /// set, the macro emits `compiled_u64(&self) -> Some(<path>(self))`
123    /// instead of building the closure from the body. Free-fn
124    /// signature: `fn(&Node) -> CompiledU64Op`, so setup-derived
125    /// state on the node is reachable. Escape hatch for hand-tuned
126    /// SIMD / FFI / unusual carriers.
127    compiled_u64_override: Option<syn::ExprPath>,
128    /// Override path for `compiled_slot()`. When set, the macro emits
129    /// `compiled_slot(&self, wire_types) -> Some(<path>(self,
130    /// wire_types))` instead of the slot kit's closure. Free-fn
131    /// signature: `fn(&Node, &[PortType]) -> CompiledSlotKit`. For a
132    /// node whose closure reads its slots as borrowed views.
133    compiled_slot_override: Option<syn::ExprPath>,
134    /// SRD-80 PR B.7 — override path for `jit_constants()`.
135    /// Free-fn signature: `fn(&Node) -> Vec<u64>`. Macro emits
136    /// `jit_constants(&self) -> <path>(self)`.
137    jit_constants_override: Option<syn::ExprPath>,
138    /// `state = <path>`: the node keeps state of its own per
139    /// evaluating kernel state (axiom S3: storage belongs to the
140    /// state, never to the shared node). The macro emits
141    /// `scratch_layout` delegating to `<path>::layout(&self)` and
142    /// `eval_in` delegating to `<path>::eval(&self, scratch, inputs,
143    /// outputs)`; the plain `eval` stays the body over fresh scratch.
144    state: Option<syn::ExprPath>,
145    /// SRD-80b Phase F (S18) — `decompose = path`. When set, the
146    /// macro emits `impl FusedNode for <Struct>` whose
147    /// `decomposed(&self)` delegates to the named free function.
148    /// Free-fn signature: `fn(&Self) -> DecomposedGraph`. The
149    /// fusion compiler reaches the equivalent unfused subgraph
150    /// through this path. Operators with bespoke fusion logic
151    /// can still `impl FusedNode` by hand alongside the macro
152    /// emission — the attribute is the canonical sugar for the
153    /// "decompose by calling one free fn" case.
154    decompose: Option<syn::ExprPath>,
155    /// SRD-80 PR B.7 — declared `Purity` (Pure / SideChannel /
156    /// Nondeterministic). Defaults to `Pure` (the trait
157    /// default). Macro emits `fn purity(&self) -> Purity::<expr>`
158    /// when present.
159    ///
160    /// Two attribute forms recognized:
161    ///
162    /// - `purity = Nondeterministic` (path) — emits `Purity::Nondeterministic`.
163    /// - `purity = SideChannel(LogBuffer)` (call) — emits the
164    ///   struct-variant form `Purity::SideChannel { sink:
165    ///   SideChannelSink::LogBuffer }`. The call-form variant
166    ///   makes the struct-variant inline attribute parse-able
167    ///   (Rust attribute grammar doesn't accept inline `{ ... }`
168    ///   struct literals as attribute values).
169    purity: Option<syn::Expr>,
170    /// DSL name of an exact, lane-wise register implementation.
171    simd: Option<syn::LitStr>,
172    /// Declares the SIMD variant total over the scalar input domain. Without
173    /// this flag the variant remains usable only after range/error proof.
174    simd_total: bool,
175    /// SRD-80 PR B.9 — variadic node identity value (the result
176    /// when called with zero inputs). Emitted into
177    /// `FuncSig.identity: Option<u64>`. Required for variadic
178    /// numeric reductions whose group has an identity (sum=0,
179    /// product=1, min=u64::MAX, max=0). Skip for variadics with
180    /// no meaningful identity (str_concat — empty list yields "").
181    identity: Option<syn::Expr>,
182    /// SRD-80 PR B.9 — `Commutativity` variant. Defaults to
183    /// `Positional`. Variadic reductions typically pass
184    /// `AllCommutative` (sum/product/min/max all hold regardless
185    /// of input order).
186    commutativity: Option<Ident>,
187    /// SRD-80 PR B.9 — minimum required wire count for variadic
188    /// nodes. Defaults to 0 (callable with zero inputs).
189    variadic_min: Option<syn::LitInt>,
190    /// SRD-80 PR B.10 — names for the elements of a tuple
191    /// return type, paired positionally with the tuple
192    /// elements. Defaults to `out_0`, `out_1`, ... when
193    /// absent. Length must match tuple arity — operator gets a
194    /// compile error otherwise.
195    output_names: Option<Vec<Ident>>,
196    /// Rust name for the generated node struct. Defaults to the
197    /// function name in PascalCase; set it when that name would
198    /// collide with a type the operator already has in scope,
199    /// such as a `ReflectedValue` type the node produces.
200    struct_name: Option<Ident>,
201}
202
203fn parse_attrs(attr: TokenStream2) -> syn::Result<NodeAttrs> {
204    if attr.is_empty() {
205        return Err(syn::Error::new(
206            proc_macro2::Span::call_site(),
207            "#[polydat_node] requires `category = <FuncCategory variant>`. \
208             Example: #[polydat_node(category = Comparison)]",
209        ));
210    }
211
212    let parser = Punctuated::<Meta, Token![,]>::parse_terminated;
213    let items = parser.parse2(attr)?;
214
215    let mut category: Option<Ident> = None;
216    let mut compiled_u64_override: Option<syn::ExprPath> = None;
217    let mut state: Option<syn::ExprPath> = None;
218    let mut compiled_slot_override: Option<syn::ExprPath> = None;
219    let mut jit_constants_override: Option<syn::ExprPath> = None;
220    let mut decompose: Option<syn::ExprPath> = None;
221    let mut purity: Option<syn::Expr> = None;
222    let mut simd: Option<syn::LitStr> = None;
223    let mut simd_total = false;
224    let mut identity: Option<syn::Expr> = None;
225    let mut commutativity: Option<Ident> = None;
226    let mut variadic_min: Option<syn::LitInt> = None;
227    let mut output_names: Option<Vec<Ident>> = None;
228    let mut struct_name: Option<Ident> = None;
229
230    for item in items {
231        match item {
232            Meta::Path(p) => {
233                let key = p
234                    .get_ident()
235                    .ok_or_else(|| {
236                        syn::Error::new_spanned(
237                            &p,
238                            "#[polydat_node] flag keys must be bare identifiers",
239                        )
240                    })?
241                    .clone();
242                match key.to_string().as_str() {
243                    "simd_total" => {
244                        simd_total = true;
245                    }
246                    other => {
247                        return Err(syn::Error::new_spanned(
248                            &key,
249                            format!(
250                                "#[polydat_node] does not recognize flag `{other}`. \
251                                 Flags: `simd_total`.",
252                            ),
253                        ));
254                    }
255                }
256            }
257            Meta::NameValue(nv) => {
258                let key = nv
259                    .path
260                    .get_ident()
261                    .ok_or_else(|| {
262                        syn::Error::new_spanned(
263                            &nv.path,
264                            "#[polydat_node] parameter keys must be bare identifiers",
265                        )
266                    })?
267                    .clone();
268                match key.to_string().as_str() {
269                    "category" => {
270                        let syn::Expr::Path(p) = &nv.value else {
271                            return Err(syn::Error::new_spanned(
272                                &nv.value,
273                                "`category` value must be a bare identifier \
274                                 (a polydat `FuncCategory` variant name).",
275                            ));
276                        };
277                        category = Some(
278                            p.path
279                                .get_ident()
280                                .ok_or_else(|| {
281                                    syn::Error::new_spanned(
282                                        &nv.value,
283                                        "`category` value must be a single identifier.",
284                                    )
285                                })?
286                                .clone(),
287                        );
288                    }
289                    "compiled_u64" => {
290                        let syn::Expr::Path(p) = &nv.value else {
291                            return Err(syn::Error::new_spanned(
292                                &nv.value,
293                                "`compiled_u64` value must be a path to a free \
294                                 function with signature `fn(&Node) -> CompiledU64Op`.",
295                            ));
296                        };
297                        compiled_u64_override = Some(p.clone());
298                    }
299                    "state" => {
300                        let syn::Expr::Path(p) = &nv.value else {
301                            return Err(syn::Error::new_spanned(
302                                &nv.value,
303                                "`state` value must be a path to a module with \
304                                 `layout(&Node) -> Vec<ScratchElem>` and \
305                                 `eval(&Node, &mut [ScratchBuf], &[Value], &mut [Value])`.",
306                            ));
307                        };
308                        state = Some(p.clone());
309                    }
310                    "compiled_slot" => {
311                        let syn::Expr::Path(p) = &nv.value else {
312                            return Err(syn::Error::new_spanned(
313                                &nv.value,
314                                "`compiled_slot` value must be a path to a free \
315                                 function with signature \
316                                 `fn(&Node, &[PortType]) -> CompiledSlotKit`.",
317                            ));
318                        };
319                        compiled_slot_override = Some(p.clone());
320                    }
321                    "jit_constants" => {
322                        let syn::Expr::Path(p) = &nv.value else {
323                            return Err(syn::Error::new_spanned(
324                                &nv.value,
325                                "`jit_constants` value must be a path to a free \
326                                 function with signature `fn(&Node) -> Vec<u64>`.",
327                            ));
328                        };
329                        jit_constants_override = Some(p.clone());
330                    }
331                    "decompose" => {
332                        let syn::Expr::Path(p) = &nv.value else {
333                            return Err(syn::Error::new_spanned(
334                                &nv.value,
335                                "`decompose` value must be a path to a free \
336                                 function with signature \
337                                 `fn(&Self) -> DecomposedGraph`.",
338                            ));
339                        };
340                        decompose = Some(p.clone());
341                    }
342                    "purity" => {
343                        // Accept either:
344                        //   purity = Nondeterministic         (path)
345                        //   purity = SideChannel(LogBuffer)   (call)
346                        // The codegen dispatches on the shape.
347                        match &nv.value {
348                            syn::Expr::Path(_) | syn::Expr::Call(_) => {
349                                purity = Some(nv.value.clone());
350                            }
351                            _ => {
352                                return Err(syn::Error::new_spanned(
353                                    &nv.value,
354                                    "`purity` value must be a Purity variant: \
355                                     `Pure`, `Nondeterministic`, or \
356                                     `SideChannel(<sink>)` where `<sink>` is a \
357                                     `SideChannelSink` variant ident.",
358                                ));
359                            }
360                        }
361                    }
362                    "simd" => {
363                        let syn::Expr::Lit(syn::ExprLit {
364                            lit: syn::Lit::Str(name),
365                            ..
366                        }) = &nv.value
367                        else {
368                            return Err(syn::Error::new_spanned(
369                                &nv.value,
370                                "`simd` value must be the string name of a register-typed node.",
371                            ));
372                        };
373                        simd = Some(name.clone());
374                    }
375                    "identity" => {
376                        // SRD-80 PR B.9 — variadic identity element.
377                        // Any constant-evaluable expression is fine.
378                        identity = Some(nv.value.clone());
379                    }
380                    "commutativity" => {
381                        let syn::Expr::Path(p) = &nv.value else {
382                            return Err(syn::Error::new_spanned(
383                                &nv.value,
384                                "`commutativity` value must be a `Commutativity` \
385                                 variant ident (Positional / AllCommutative / ...).",
386                            ));
387                        };
388                        commutativity = Some(
389                            p.path
390                                .get_ident()
391                                .ok_or_else(|| {
392                                    syn::Error::new_spanned(
393                                        &nv.value,
394                                        "`commutativity` value must be a single identifier.",
395                                    )
396                                })?
397                                .clone(),
398                        );
399                    }
400                    "variadic_min" => {
401                        let syn::Expr::Lit(syn::ExprLit {
402                            lit: syn::Lit::Int(n),
403                            ..
404                        }) = &nv.value
405                        else {
406                            return Err(syn::Error::new_spanned(
407                                &nv.value,
408                                "`variadic_min` value must be an integer literal.",
409                            ));
410                        };
411                        variadic_min = Some(n.clone());
412                    }
413                    "struct_name" => {
414                        // The generated Rust struct is named after the
415                        // function in PascalCase by default; a host whose
416                        // module already has a type of that name picks
417                        // another one here. The DSL name is unchanged.
418                        let syn::Expr::Path(p) = &nv.value else {
419                            return Err(syn::Error::new_spanned(
420                                &nv.value,
421                                "`struct_name` value must be a bare identifier, \
422                                 e.g. `struct_name = GeoCellNode`.",
423                            ));
424                        };
425                        struct_name = Some(p.path.get_ident()
426                            .ok_or_else(|| syn::Error::new_spanned(
427                                &nv.value,
428                                "`struct_name` value must be a single identifier, not a path.",
429                            ))?
430                            .clone());
431                    }
432                    other => {
433                        return Err(syn::Error::new_spanned(
434                            &key,
435                            format!(
436                                "#[polydat_node] does not recognize parameter `{other}`. \
437                                 Registration: `category = <FuncCategory>`, \
438                                 `struct_name = <Ident>`. \
439                                 Engines: `compiled_u64 = <path>`, \
440                                 `compiled_slot = <path>`, `state = <path>`, \
441                                 `jit_constants = <path>`, `decompose = <path>`, \
442                                 `simd = \"<node>\"`, `simd_total`. \
443                                 Semantics: `purity = <Purity>`, `identity = <expr>`, \
444                                 `commutativity = <Commutativity>`, `variadic_min = <int>`. \
445                                 Shapes: `output_names(...)`.",
446                            ),
447                        ));
448                    }
449                }
450            }
451            Meta::List(list) => {
452                let key = list
453                    .path
454                    .get_ident()
455                    .ok_or_else(|| {
456                        syn::Error::new_spanned(
457                            &list.path,
458                            "#[polydat_node] list-form keys must be bare identifiers",
459                        )
460                    })?
461                    .clone();
462                match key.to_string().as_str() {
463                    "output_names" => {
464                        let names: Punctuated<Ident, Token![,]> =
465                            list.parse_args_with(Punctuated::parse_terminated)?;
466                        if names.is_empty() {
467                            return Err(syn::Error::new_spanned(
468                                &list,
469                                "`output_names(...)` requires at least one name.",
470                            ));
471                        }
472                        output_names = Some(names.into_iter().collect());
473                    }
474                    other => {
475                        return Err(syn::Error::new_spanned(
476                            &key,
477                            format!(
478                                "#[polydat_node] does not recognize list-form key `{other}`. \
479                                 Recognised: `output_names(...)`.",
480                            ),
481                        ));
482                    }
483                }
484            }
485        }
486    }
487
488    let category = category.ok_or_else(|| {
489        syn::Error::new(
490            proc_macro2::Span::call_site(),
491            "#[polydat_node] requires `category = <FuncCategory variant>`.",
492        )
493    })?;
494
495    if simd_total && simd.is_none() {
496        return Err(syn::Error::new(
497            proc_macro2::Span::call_site(),
498            "`simd_total` requires `simd = \"<register node>\"`.",
499        ));
500    }
501
502    Ok(NodeAttrs {
503        category,
504        compiled_u64_override,
505        compiled_slot_override,
506        jit_constants_override,
507        state,
508        decompose,
509        purity,
510        simd,
511        simd_total,
512        identity,
513        commutativity,
514        variadic_min,
515        output_names,
516        struct_name,
517    })
518}
519
520/// One classified function argument. Drives every downstream
521/// piece of the generated output: NodeMeta slot, FuncSig
522/// param, struct field (for consts), build closure const
523/// extraction, eval-time wrapper construction.
524struct ClassifiedArg {
525    name: syn::Ident,
526    /// Original Rust type from the function signature.
527    declared_ty: Type,
528    /// Whether the arg was declared as `Const<T>`.
529    kind: ArgKind,
530    /// For const args: optional default value expression parsed
531    /// from `#[poly_default(VAL)]`. Present → the const is
532    /// optional in FuncSig and the build closure falls back to
533    /// the default when the consts slice doesn't supply one.
534    default_value: Option<syn::Expr>,
535    /// SRD-80 PR B.14 — `#[constraint(<Variant>)]` on a wire
536    /// arg. The variant name maps to `ConstConstraint::*`; the
537    /// emitted `Port` carries the constraint so strict-wire
538    /// mode can auto-insert upstream assertion nodes.
539    wire_constraint: Option<Ident>,
540}
541
542#[derive(Clone)]
543enum ArgKind {
544    Wire,
545    Const(ConstShape),
546    /// SRD-80b Phase C — `Const<Vec<C>>` workload-list const.
547    /// Inner ConstShape gives the element type (u64/f64/bool/Str).
548    /// The macro emits ONE ParamSpec in the FuncSig with the
549    /// inner element's slot type, sets `Arity::VariadicConsts`,
550    /// and at build time collects every matching ConstArg from
551    /// the tail of `consts[..]` into a `Vec<inner>` field.
552    /// Eval hands the body a `Const(self.field.clone())`.
553    ConstVec(ConstShape),
554    /// `&T` argument with `#[poly_const(<fn_path>, from = <arg>)]`.
555    /// Generates a struct field of type `T`, computed once in
556    /// `new()` by calling `<fn_path>(<source>)` where `<source>`
557    /// is the field-access expression for the named `from` arg.
558    /// Boxed: `SetupSpec` is ~424 bytes, dwarfing the other
559    /// variants — indirection keeps `ArgKind` small.
560    Setup(Box<SetupSpec>),
561    /// SRD-80 PR B.8 — `Value` argument. Polymorphic wire whose
562    /// port type is resolved at construction (`new()` takes a
563    /// runtime `PortType`). Body sees a cloned `Value`; eval
564    /// box/unboxes via the trivial `FromValue<Value>` impl.
565    /// Triggers `OutputType::SameAsInput(<this idx>)` when the
566    /// return type is also `Value`.
567    PolyWire,
568    /// SRD-80 PR B.9 — `&[T]` argument (variadic wire). Construction
569    /// is runtime-arity (`new(n_wires)`); the macro emits N wire
570    /// slots, an `Arity::VariadicWires { min_wires }` FuncSig
571    /// entry, and a `variadic_ctor` thunk that builds with `n`
572    /// at compile time.
573    Variadic(VariadicElement),
574}
575
576/// Element type of a `&[T]` variadic arg. Determines the
577/// per-element port type, whether the node stays JIT-eligible,
578/// and how `eval()` materialises the slice for the body call.
579#[derive(Clone, Copy, PartialEq, Eq)]
580enum VariadicElement {
581    U64,
582    Bool,
583    BorrowedStr,
584    OwnedString,
585    /// `&[Value]` — polymorphic per-element type. The body sees
586    /// each element as the polydat runtime carrier; type
587    /// inspection / coercion is the body's responsibility.
588    Value,
589}
590
591impl VariadicElement {
592    fn port_type_tokens(self) -> TokenStream2 {
593        // For Value variadics we declare the per-slot port type
594        // as Str (the most common stringy use case — printf,
595        // str_concat). The body deals with type coercion via
596        // its own dispatch on the Value variant.
597        match self {
598            VariadicElement::U64 => quote!(polydat::ast::PortType::U64),
599            VariadicElement::Bool => quote!(polydat::ast::PortType::Bool),
600            VariadicElement::BorrowedStr => quote!(polydat::ast::PortType::Str),
601            VariadicElement::OwnedString => quote!(polydat::ast::PortType::Str),
602            VariadicElement::Value => quote!(polydat::ast::PortType::Str),
603        }
604    }
605
606    /// Expression that converts a single `&Value` to the body's
607    /// element type. Used to build the per-call slice in eval().
608    fn extract_from_value(self) -> TokenStream2 {
609        match self {
610            VariadicElement::U64 => quote!(|v: &polydat::ast::Value| v.as_u64()),
611            VariadicElement::Bool => quote!(|v: &polydat::ast::Value| v.as_bool()),
612            VariadicElement::BorrowedStr => quote!(|v: &polydat::ast::Value| v.as_str()),
613            VariadicElement::OwnedString => {
614                quote!(|v: &polydat::ast::Value| v.as_str().to_string())
615            }
616            VariadicElement::Value => quote!(|v: &polydat::ast::Value| v.clone()),
617        }
618    }
619}
620
621#[derive(Clone)]
622struct SetupSpec {
623    /// `T` — the type the field stores (inner type of `&T`).
624    inner_ty: Type,
625    /// Operator-provided constructor path, e.g.
626    /// `ParsedPattern::from_pattern`.
627    setup_fn: syn::Expr,
628    /// Names of the const args whose field-values are passed to
629    /// `setup_fn`. Empty when declared as `from = ()` — the
630    /// setup fn takes no arguments and captures session-static
631    /// state (env, system clock, etc.). Length 1 for the common
632    /// single-source case (`from = ident`); length N for
633    /// multi-source `from = (a, b, c)` per SRD-80b amendment.
634    source_args: Vec<syn::Ident>,
635}
636
637#[derive(Clone, Copy, PartialEq, Eq)]
638enum ConstShape {
639    U64,
640    F64,
641    Bool,
642    Str,
643}
644
645impl ConstShape {
646    /// Token stream for the `SlotType::Const*` variant.
647    fn slot_type_tokens(self) -> TokenStream2 {
648        match self {
649            ConstShape::U64 => quote!(polydat::ast::SlotType::ConstU64),
650            ConstShape::F64 => quote!(polydat::ast::SlotType::ConstF64),
651            ConstShape::Bool => quote!(polydat::ast::SlotType::ConstU64),
652            ConstShape::Str => quote!(polydat::ast::SlotType::ConstStr),
653        }
654    }
655
656    /// Token stream for the struct field type that stores the
657    /// captured const value. `Const<&str>` → `String` (owned
658    /// backing store). Other shapes are Copy and stored
659    /// directly.
660    fn field_type_tokens(self) -> TokenStream2 {
661        match self {
662            ConstShape::U64 => quote!(u64),
663            ConstShape::F64 => quote!(f64),
664            ConstShape::Bool => quote!(bool),
665            ConstShape::Str => quote!(String),
666        }
667    }
668
669    /// Token stream that extracts a value from a `ConstArg`.
670    /// `c` is the `ConstArg` binding in scope at the call site.
671    fn extract_from_const_arg(self, c: TokenStream2) -> TokenStream2 {
672        match self {
673            ConstShape::U64 => quote!(#c.as_u64()),
674            ConstShape::F64 => quote!(#c.as_f64()),
675            ConstShape::Bool => quote!(#c.as_u64() != 0),
676            ConstShape::Str => quote!(#c.as_str().to_string()),
677        }
678    }
679
680    /// Token stream that wraps a struct-field expression as
681    /// `Const<T>` for handoff into the user's function body.
682    /// `field_ref` is the borrow / value expression for the
683    /// stored field (e.g. `&self.pattern` or `self.seed`).
684    fn wrap_as_const(self, field_ref: TokenStream2) -> TokenStream2 {
685        match self {
686            ConstShape::U64 => quote!(polydat::derive_support::Const(#field_ref)),
687            ConstShape::F64 => quote!(polydat::derive_support::Const(#field_ref)),
688            ConstShape::Bool => quote!(polydat::derive_support::Const(#field_ref)),
689            ConstShape::Str => quote!(polydat::derive_support::Const(#field_ref.as_str())),
690        }
691    }
692}
693
694/// SRD-80 PR B.7 — primitive types that fit the JIT u64 buffer.
695/// A node is Phase-2 eligible iff every wire arg / const arg /
696/// return type maps to a `JitType` and no `#[poly_const]` setup arg
697/// is declared (setup carries non-primitive derived state).
698#[derive(Clone, Copy, PartialEq, Eq)]
699enum JitType {
700    U64,
701    I64,
702    F64,
703    Bool,
704    // Narrow widths (alignment §8.1): each rides the u64 slot per
705    // its Wire storage convention — unsigned zero-extended, signed
706    // sign-extended (through the i64 carrier), floats bit-stuffed.
707    // The variant carries enough width information for the buffer
708    // read/write tokens to emit the exact narrowing/widening casts.
709    U8,
710    U16,
711    U32,
712    I8,
713    I16,
714    I32,
715    F32,
716    F16,
717    // Two-slot values (alignment §8.4 layer 1): 128-bit integers
718    // and register words ride two consecutive u64 slots in
719    // little-endian limb order, reconstructed through
720    // `polydat::ast::Bits128`.
721    U128,
722    I128,
723    RegRaw,
724    RegI8x16,
725    RegI16x8,
726    RegI32x4,
727    RegI64x2,
728    RegF16x8,
729    RegF32x4,
730    RegF64x2,
731}
732
733impl JitType {
734    /// Buffer slots this carrier occupies (alignment §8.4 layer
735    /// 1): 1 for everything riding a single u64; 2 for 128-bit
736    /// values (limb pairs).
737    fn width(self) -> usize {
738        match self {
739            JitType::U128
740            | JitType::I128
741            | JitType::RegRaw
742            | JitType::RegI8x16
743            | JitType::RegI16x8
744            | JitType::RegI32x4
745            | JitType::RegI64x2
746            | JitType::RegF16x8
747            | JitType::RegF32x4
748            | JitType::RegF64x2 => 2,
749            _ => 1,
750        }
751    }
752
753    /// Tokens reading a typed value from the Phase-2 u64 buffer
754    /// at slot offset `idx` (the prefix sum of the widths of all
755    /// preceding wire args). f64/bool are bit-reinterpreted from
756    /// the u64 carrier (the buffer-level convention shared with
757    /// every existing hand-written `compiled_u64`); two-slot
758    /// values reassemble through `Bits128`.
759    fn read_from_u64_buffer(self, idx: usize) -> TokenStream2 {
760        let i = syn::Index::from(idx);
761        let i1 = syn::Index::from(idx + 1);
762        let limbs = quote!(polydat::ast::Bits128([inputs[#i], inputs[#i1]]));
763        match self {
764            JitType::U64 => quote!(inputs[#i]),
765            JitType::I64 => quote!(inputs[#i] as i64),
766            JitType::F64 => quote!(f64::from_bits(inputs[#i])),
767            JitType::Bool => quote!(inputs[#i] != 0),
768            JitType::U8 => quote!(inputs[#i] as u8),
769            JitType::U16 => quote!(inputs[#i] as u16),
770            JitType::U32 => quote!(inputs[#i] as u32),
771            JitType::I8 => quote!((inputs[#i] as i64) as i8),
772            JitType::I16 => quote!((inputs[#i] as i64) as i16),
773            JitType::I32 => quote!((inputs[#i] as i64) as i32),
774            JitType::F32 => quote!(f32::from_bits(inputs[#i] as u32)),
775            JitType::F16 => quote!(polydat::half::f16::from_bits(inputs[#i] as u16)),
776            JitType::U128 => quote!((#limbs).as_u128()),
777            JitType::I128 => quote!((#limbs).as_i128()),
778            JitType::RegRaw => limbs,
779            JitType::RegI8x16 => quote!((#limbs).lanes_i8()),
780            JitType::RegI16x8 => quote!((#limbs).lanes_i16()),
781            JitType::RegI32x4 => quote!((#limbs).lanes_i32()),
782            JitType::RegI64x2 => quote!((#limbs).lanes_i64()),
783            JitType::RegF16x8 => quote!((#limbs).lanes_f16()),
784            JitType::RegF32x4 => quote!((#limbs).lanes_f32()),
785            JitType::RegF64x2 => quote!((#limbs).lanes_f64()),
786        }
787    }
788
789    /// Tokens writing a typed value into the Phase-2 u64 output
790    /// buffer at slot offset `base`. Inverse of the read.
791    fn write_to_u64_buffer_at(self, base: usize, result: TokenStream2) -> TokenStream2 {
792        let o = syn::Index::from(base);
793        let o1 = syn::Index::from(base + 1);
794        let write_limbs = |from: TokenStream2| {
795            quote! {{
796                let __limbs = #from;
797                outputs[#o] = __limbs.0[0];
798                outputs[#o1] = __limbs.0[1];
799            }}
800        };
801        match self {
802            JitType::U64 => quote!(outputs[#o] = #result;),
803            JitType::I64 => quote!(outputs[#o] = (#result) as u64;),
804            JitType::F64 => quote!(outputs[#o] = (#result).to_bits();),
805            JitType::Bool => quote!(outputs[#o] = if #result { 1 } else { 0 };),
806            JitType::U8 | JitType::U16 | JitType::U32 => quote!(outputs[#o] = (#result) as u64;),
807            JitType::I8 | JitType::I16 | JitType::I32 => {
808                quote!(outputs[#o] = ((#result) as i64) as u64;)
809            }
810            JitType::F32 => quote!(outputs[#o] = (#result).to_bits() as u64;),
811            JitType::F16 => quote!(outputs[#o] = (#result).to_bits() as u64;),
812            JitType::U128 => write_limbs(quote!(polydat::ast::Bits128::from_u128(#result))),
813            JitType::I128 => write_limbs(quote!(polydat::ast::Bits128::from_i128(#result))),
814            JitType::RegRaw => write_limbs(quote!(#result)),
815            JitType::RegI8x16 => write_limbs(quote!(polydat::ast::Bits128::from_lanes_i8(#result))),
816            JitType::RegI16x8 => {
817                write_limbs(quote!(polydat::ast::Bits128::from_lanes_i16(#result)))
818            }
819            JitType::RegI32x4 => {
820                write_limbs(quote!(polydat::ast::Bits128::from_lanes_i32(#result)))
821            }
822            JitType::RegI64x2 => {
823                write_limbs(quote!(polydat::ast::Bits128::from_lanes_i64(#result)))
824            }
825            JitType::RegF16x8 => {
826                write_limbs(quote!(polydat::ast::Bits128::from_lanes_f16(#result)))
827            }
828            JitType::RegF32x4 => {
829                write_limbs(quote!(polydat::ast::Bits128::from_lanes_f32(#result)))
830            }
831            JitType::RegF64x2 => {
832                write_limbs(quote!(polydat::ast::Bits128::from_lanes_f64(#result)))
833            }
834        }
835    }
836
837    /// Single-return write at offset 0.
838    fn write_to_u64_buffer(self, result: TokenStream2) -> TokenStream2 {
839        self.write_to_u64_buffer_at(0, result)
840    }
841
842    /// Tokens encoding the captured Copy value of a const field
843    /// as a `u64` for `jit_constants()` (Phase-3 classifier).
844    fn const_field_as_u64(self, field_ref: TokenStream2) -> TokenStream2 {
845        match self {
846            JitType::U64 => quote!(#field_ref),
847            JitType::I64 => quote!((#field_ref) as u64),
848            JitType::F64 => quote!((#field_ref).to_bits()),
849            JitType::Bool => quote!(if #field_ref { 1 } else { 0 }),
850            JitType::U8 | JitType::U16 | JitType::U32 => quote!((#field_ref) as u64),
851            JitType::I8 | JitType::I16 | JitType::I32 => quote!(((#field_ref) as i64) as u64),
852            JitType::F32 | JitType::F16 => quote!((#field_ref).to_bits() as u64),
853            // ConstShape has no 128-bit / register forms, so these
854            // never appear in const position.
855            JitType::U128
856            | JitType::I128
857            | JitType::RegRaw
858            | JitType::RegI8x16
859            | JitType::RegI16x8
860            | JitType::RegI32x4
861            | JitType::RegI64x2
862            | JitType::RegF16x8
863            | JitType::RegF32x4
864            | JitType::RegF64x2 => {
865                unreachable!("128-bit/register types have no const shape")
866            }
867        }
868    }
869}
870
871/// Map a `ConstShape` to its JIT-compatible primitive carrier,
872/// or `None` if the shape can't live in the u64 buffer.
873fn const_shape_to_jit_type(s: ConstShape) -> Option<JitType> {
874    match s {
875        ConstShape::U64 => Some(JitType::U64),
876        ConstShape::F64 => Some(JitType::F64),
877        ConstShape::Bool => Some(JitType::Bool),
878        // A string constant never rides the buffer: the kits capture
879        // it by clone, and native lowerings read it from the node.
880        ConstShape::Str => None,
881    }
882}
883
884/// Map a wire arg's declared Rust type to its JIT carrier, or
885/// `None` for types that can't fit in the buffer.
886fn wire_type_to_jit_type(ty: &Type) -> Option<JitType> {
887    // type_to_string joins every token with a space, and a token
888    // may itself be a bracketed group (`& [u8]`, `half : : f16`,
889    // `[ f32 ; 4 ]`), so every form compares whitespace-stripped.
890    // The two-slot types ride limb pairs per alignment §8.4 layer 1.
891    let flat: String = type_to_string(ty).split_whitespace().collect();
892    match flat.as_str() {
893        "u64" => Some(JitType::U64),
894        "i64" => Some(JitType::I64),
895        "f64" => Some(JitType::F64),
896        "bool" => Some(JitType::Bool),
897        "u8" => Some(JitType::U8),
898        "u16" => Some(JitType::U16),
899        "u32" => Some(JitType::U32),
900        "i8" => Some(JitType::I8),
901        "i16" => Some(JitType::I16),
902        "i32" => Some(JitType::I32),
903        "f32" => Some(JitType::F32),
904        "u128" => Some(JitType::U128),
905        "i128" => Some(JitType::I128),
906        "half::f16" | "f16" => Some(JitType::F16),
907        "Bits128" | "crate::ast::Bits128" | "polydat::ast::Bits128" | "ast::Bits128" => {
908            Some(JitType::RegRaw)
909        }
910        "[i8;16]" => Some(JitType::RegI8x16),
911        "[i16;8]" => Some(JitType::RegI16x8),
912        "[i32;4]" => Some(JitType::RegI32x4),
913        "[i64;2]" => Some(JitType::RegI64x2),
914        "[half::f16;8]" | "[f16;8]" => Some(JitType::RegF16x8),
915        "[f32;4]" => Some(JitType::RegF32x4),
916        "[f64;2]" => Some(JitType::RegF64x2),
917        _ => None,
918    }
919}
920
921/// The `T` of an `Option<T>` argument, by its last path segment.
922fn option_inner(ty: &Type) -> Option<&Type> {
923    generic_inner(ty, "Option")
924}
925
926/// The `T` of a `Config<T>` argument, by its last path segment.
927fn config_inner(ty: &Type) -> Option<&Type> {
928    generic_inner(ty, "Config")
929}
930
931fn generic_inner<'a>(ty: &'a Type, wrapper: &str) -> Option<&'a Type> {
932    let syn::Type::Path(p) = ty else {
933        return None;
934    };
935    let last = p.path.segments.last()?;
936    if last.ident != wrapper {
937        return None;
938    }
939    let syn::PathArguments::AngleBracketed(args) = &last.arguments else {
940        return None;
941    };
942    args.args.iter().find_map(|a| match a {
943        syn::GenericArgument::Type(t) => Some(t),
944        _ => None,
945    })
946}
947
948/// Detect `Const<T>` in arg-type position. Returns `Some(shape)`
949/// for recognized inner types; `None` for bare types (wire) or
950/// unrecognized shapes. The recognition is structural — matches
951/// the last segment of the path as `Const` with a single
952/// generic argument resolving to a primitive type the macro
953/// supports.
954fn classify_type(ty: &Type) -> Option<ConstShape> {
955    let syn::Type::Path(p) = ty else {
956        return None;
957    };
958    let last = p.path.segments.last()?;
959    if last.ident != "Const" {
960        return None;
961    }
962    let syn::PathArguments::AngleBracketed(args) = &last.arguments else {
963        return None;
964    };
965    let inner = args.args.iter().find_map(|a| {
966        if let syn::GenericArgument::Type(t) = a {
967            Some(t)
968        } else {
969            None
970        }
971    })?;
972    let s = type_to_string(inner);
973    match s.as_str() {
974        "u64" => Some(ConstShape::U64),
975        "f64" => Some(ConstShape::F64),
976        "bool" => Some(ConstShape::Bool),
977        "& str" | "&str" => Some(ConstShape::Str),
978        _ => None,
979    }
980}
981
982/// SRD-80b Phase C — detect `Const<Vec<T>>` in arg position.
983/// Returns the inner element shape on match. Distinct path
984/// from [`classify_type`]: the macro recognises the variadic-
985/// const shape before the scalar `Const<T>` shape, so a
986/// signature using `Const<Vec<u64>>` doesn't get misclassified.
987fn classify_const_vec(ty: &Type) -> Option<ConstShape> {
988    // Outer must be Const<...>.
989    let syn::Type::Path(p) = ty else {
990        return None;
991    };
992    let last = p.path.segments.last()?;
993    if last.ident != "Const" {
994        return None;
995    }
996    let syn::PathArguments::AngleBracketed(args) = &last.arguments else {
997        return None;
998    };
999    let inner = args.args.iter().find_map(|a| {
1000        if let syn::GenericArgument::Type(t) = a {
1001            Some(t)
1002        } else {
1003            None
1004        }
1005    })?;
1006    // Inner must be Vec<X>.
1007    let syn::Type::Path(vp) = inner else {
1008        return None;
1009    };
1010    let vlast = vp.path.segments.last()?;
1011    if vlast.ident != "Vec" {
1012        return None;
1013    }
1014    let syn::PathArguments::AngleBracketed(vargs) = &vlast.arguments else {
1015        return None;
1016    };
1017    let velem = vargs.args.iter().find_map(|a| {
1018        if let syn::GenericArgument::Type(t) = a {
1019            Some(t)
1020        } else {
1021            None
1022        }
1023    })?;
1024    let s = type_to_string(velem);
1025    match s.as_str() {
1026        "u64" => Some(ConstShape::U64),
1027        "f64" => Some(ConstShape::F64),
1028        "bool" => Some(ConstShape::Bool),
1029        "String" => Some(ConstShape::Str),
1030        "& str" | "&str" => Some(ConstShape::Str),
1031        _ => None,
1032    }
1033}
1034
1035/// SRD-80b dynamic-output shape — detect
1036/// `DynamicOutputs<T>` in return position. Returns the inner
1037/// element type `T` on match. The macro pairs this with the
1038/// function's `Const<Vec<C>>` arg to compute the output port
1039/// count at construction time.
1040fn classify_dynamic_outputs(ty: &Type) -> Option<Type> {
1041    let syn::Type::Path(p) = ty else {
1042        return None;
1043    };
1044    let last = p.path.segments.last()?;
1045    if last.ident != "DynamicOutputs" {
1046        return None;
1047    }
1048    let syn::PathArguments::AngleBracketed(args) = &last.arguments else {
1049        return None;
1050    };
1051    args.args.iter().find_map(|a| {
1052        if let syn::GenericArgument::Type(t) = a {
1053            Some(t.clone())
1054        } else {
1055            None
1056        }
1057    })
1058}
1059
1060/// Extract a `#[poly_default(EXPR)]` attribute from an arg's
1061/// outer attributes, if present. Returns the inner expression
1062/// token stream so the build closure can use it as the
1063/// fallback when the runtime `consts` slice is shorter than
1064/// the declared param list.
1065fn parse_poly_default(attrs: &[syn::Attribute]) -> syn::Result<Option<syn::Expr>> {
1066    for attr in attrs {
1067        if !attr.path().is_ident("poly_default") {
1068            continue;
1069        }
1070        let expr: syn::Expr = attr.parse_args()?;
1071        return Ok(Some(expr));
1072    }
1073    Ok(None)
1074}
1075
1076/// Extract a `#[constraint(<Variant>)]` attribute. SRD-80 PR
1077/// B.14 — wire-arg constraint metadata. The variant name
1078/// matches `ConstConstraint::*` (e.g. `NonZeroU64`,
1079/// `PositiveFiniteF64`). Strict-wire mode reads this metadata
1080/// to auto-insert assertion nodes upstream.
1081fn parse_wire_constraint(attrs: &[syn::Attribute]) -> syn::Result<Option<Ident>> {
1082    for attr in attrs {
1083        if !attr.path().is_ident("constraint") {
1084            continue;
1085        }
1086        let variant: Ident = attr.parse_args()?;
1087        return Ok(Some(variant));
1088    }
1089    Ok(None)
1090}
1091
1092/// Extract a `#[poly_const(<fn_expr>, from = <source>)]` attribute
1093/// from an arg's outer attributes, if present. Returns the
1094/// constructor expression and the source identifiers.
1095///
1096/// SRD-80b — `from` accepts three shapes:
1097///   - `from = ()` — empty source. Setup fn takes no args;
1098///     captures session-static state (env, system clock).
1099///   - `from = ident` — single source. Setup fn called as
1100///     `setup_fn(ident_value)`.
1101///   - `from = (a, b, c)` — multi-source (SRD-80b amendment).
1102///     Setup fn called as `setup_fn(a_value, b_value, c_value)`.
1103///     Order matches the tuple. Each name must reference a
1104///     `Const<T>` arg declared in the same function signature.
1105fn parse_poly_const(attrs: &[syn::Attribute]) -> syn::Result<Option<(syn::Expr, Vec<syn::Ident>)>> {
1106    for attr in attrs {
1107        if !attr.path().is_ident("poly_const") {
1108            continue;
1109        }
1110        let parser = |input: syn::parse::ParseStream| -> syn::Result<(syn::Expr, Vec<syn::Ident>)> {
1111            let fn_expr: syn::Expr = input.parse()?;
1112            let _comma: Token![,] = input.parse()?;
1113            let from_kw: syn::Ident = input.parse()?;
1114            if from_kw != "from" {
1115                return Err(syn::Error::new_spanned(
1116                    from_kw,
1117                    "#[poly_const(...)] requires a `from = <source>` clause. \
1118                     Supported shapes: `from = ()` (empty), `from = ident` \
1119                     (single), `from = (a, b, c)` (multi-source).",
1120                ));
1121            }
1122            let _eq: Token![=] = input.parse()?;
1123            // Parenthesised forms: `from = ()` or `from = (a, b, c)`.
1124            if input.peek(syn::token::Paren) {
1125                let inner;
1126                let _paren = syn::parenthesized!(inner in input);
1127                if inner.is_empty() {
1128                    return Ok((fn_expr, Vec::new()));
1129                }
1130                let parsed: Punctuated<syn::Ident, Token![,]> =
1131                    Punctuated::parse_terminated(&inner)?;
1132                if parsed.is_empty() {
1133                    return Err(syn::Error::new_spanned(
1134                        from_kw,
1135                        "#[poly_const(..., from = (...))] — the parenthesised \
1136                         form expects a comma-separated list of source-arg \
1137                         identifiers, or an empty `()` for session-static \
1138                         setup.",
1139                    ));
1140                }
1141                return Ok((fn_expr, parsed.into_iter().collect()));
1142            }
1143            // Bare `from = ident` — single source.
1144            let source: syn::Ident = input.parse()?;
1145            Ok((fn_expr, vec![source]))
1146        };
1147        let parsed = attr.parse_args_with(parser)?;
1148        return Ok(Some(parsed));
1149    }
1150    Ok(None)
1151}
1152
1153/// Detect `&T` for some `T` in arg-type position. Returns
1154/// `Some(inner_t)` on match, `None` otherwise. Used for the
1155/// PR B.6 setup-arg dispatch.
1156fn classify_borrowed(ty: &Type) -> Option<Type> {
1157    let syn::Type::Reference(r) = ty else {
1158        return None;
1159    };
1160    if r.mutability.is_some() {
1161        return None;
1162    }
1163    Some((*r.elem).clone())
1164}
1165
1166/// Detect `Value` in arg-type position. SRD-80 PR B.8 —
1167/// polymorphic wire dispatch. Matches the last path segment
1168/// being `Value`, so both `Value` and `polydat::ast::Value`
1169/// (and any other fully-qualified path ending in `Value`) work.
1170fn classify_polywire(ty: &Type) -> bool {
1171    let syn::Type::Path(p) = ty else {
1172        return false;
1173    };
1174    p.path
1175        .segments
1176        .last()
1177        .map(|s| s.ident == "Value")
1178        .unwrap_or(false)
1179}
1180
1181/// SRD-80 PR B.11/B.13 — structural classifier for the
1182/// wrapper-typed wire arg shapes. Returns the matching wire
1183/// kind, or `None` if the type isn't one of the recognised
1184/// wrapper shapes.
1185#[derive(Clone, Copy, PartialEq, Eq)]
1186enum WrapperWire {
1187    Bytes,
1188    Json,
1189    /// `Arc<T>` for some T that isn't `[u8]` or `serde_json::Value`.
1190    /// Inline-downcast in arg_bindings; inline-upcast in
1191    /// result_to_outputs. Handle dispatch.
1192    Handle,
1193    /// One of the seven typed vector variants: `VecF32` / `VecI32`
1194    /// / `VecF64` / `VecI64` / `VecF16` / `VecI16` / `VecI8`. The
1195    /// macro emits the matching `PortType::Vec*`; the Wire impls in
1196    /// derive_support are autogenerated from a macro_rules!
1197    /// expansion per element type.
1198    VecF32,
1199    VecI32,
1200    VecF64,
1201    VecI64,
1202    VecF16,
1203    VecI16,
1204    VecI8,
1205}
1206
1207fn classify_wrapper_wire(ty: &Type) -> Option<WrapperWire> {
1208    // SRD-80 PR B.13 — typed vectors. Check first to catch
1209    // `Vec<f32>` etc. before they fall into Handle territory
1210    // (which is the catch-all for Arc<T>).
1211    if let Some(kind) = classify_vec_wire(ty) {
1212        return Some(kind);
1213    }
1214
1215    // `Arc<[u8]>` — Arc with [u8] generic.
1216    if let Some(inner) = strip_arc(ty)
1217        && let syn::Type::Slice(slc) = inner
1218        && let syn::Type::Path(p) = &*slc.elem
1219        && p.path.is_ident("u8")
1220    {
1221        return Some(WrapperWire::Bytes);
1222    }
1223    // `Arc<serde_json::Value>` / `Arc<Value>` (last segment).
1224    if let Some(inner) = strip_arc(ty)
1225        && let syn::Type::Path(p) = inner
1226        && last_segment_is(p, "Value")
1227        && path_contains_segment(p, "serde_json")
1228    {
1229        return Some(WrapperWire::Json);
1230    }
1231    // `Arc<str>` — Str port via the dedicated Wire impl. Don't
1232    // route through Handle (str isn't Sized so the Handle's
1233    // `Value::handle<T: Sized>` constructor would reject it).
1234    if let Some(inner) = strip_arc(ty)
1235        && let syn::Type::Path(p) = inner
1236        && p.path.is_ident("str")
1237    {
1238        return None;
1239    }
1240    // `Arc<dyn Any + Send + Sync>` — Handle via the dedicated
1241    // Wire impl. Fall through to trait dispatch rather than
1242    // the structural Handle path (which expects a concrete
1243    // Arc<ConcreteT> for the downcast).
1244    if let Some(inner) = strip_arc(ty)
1245        && matches!(inner, syn::Type::TraitObject(_))
1246    {
1247        return None;
1248    }
1249    // Any other `Arc<T>` is a Handle.
1250    if strip_arc(ty).is_some() {
1251        return Some(WrapperWire::Handle);
1252    }
1253    // `Vec<u8>`.
1254    if let syn::Type::Path(p) = ty
1255        && let Some(last) = p.path.segments.last()
1256        && last.ident == "Vec"
1257        && let syn::PathArguments::AngleBracketed(args) = &last.arguments
1258        && let Some(syn::GenericArgument::Type(syn::Type::Path(elem))) = args.args.first()
1259        && elem.path.is_ident("u8")
1260    {
1261        return Some(WrapperWire::Bytes);
1262    }
1263    // `&[u8]` — borrowed bytes.
1264    if let syn::Type::Reference(r) = ty
1265        && r.mutability.is_none()
1266        && let syn::Type::Slice(slc) = &*r.elem
1267        && let syn::Type::Path(p) = &*slc.elem
1268        && p.path.is_ident("u8")
1269    {
1270        return Some(WrapperWire::Bytes);
1271    }
1272    // `&serde_json::Value`.
1273    if let syn::Type::Reference(r) = ty
1274        && r.mutability.is_none()
1275        && let syn::Type::Path(p) = &*r.elem
1276        && last_segment_is(p, "Value")
1277        && path_contains_segment(p, "serde_json")
1278    {
1279        return Some(WrapperWire::Json);
1280    }
1281    None
1282}
1283
1284fn strip_arc(ty: &Type) -> Option<&Type> {
1285    let syn::Type::Path(p) = ty else {
1286        return None;
1287    };
1288    let last = p.path.segments.last()?;
1289    if last.ident != "Arc" {
1290        return None;
1291    }
1292    let syn::PathArguments::AngleBracketed(args) = &last.arguments else {
1293        return None;
1294    };
1295    args.args.iter().find_map(|a| match a {
1296        syn::GenericArgument::Type(t) => Some(t),
1297        _ => None,
1298    })
1299}
1300
1301fn last_segment_is(p: &syn::TypePath, name: &str) -> bool {
1302    p.path
1303        .segments
1304        .last()
1305        .map(|s| s.ident == name)
1306        .unwrap_or(false)
1307}
1308
1309fn path_contains_segment(p: &syn::TypePath, name: &str) -> bool {
1310    p.path.segments.iter().any(|s| s.ident == name)
1311}
1312
1313/// For a `Handle` arg, extract the inner T (the downcast target).
1314fn extract_handle_inner(ty: &Type) -> Option<Type> {
1315    strip_arc(ty).cloned()
1316}
1317
1318/// `Option<T>` recognition. Returns `true` if the type's last
1319/// path segment is `Option` with a single generic argument. Used
1320/// to decide whether to auto-emit `accepts_none_inputs() -> true`
1321/// — the runtime kernel's SRD-74 Rule 1 short-circuits `Value::None`
1322/// inputs on opt-in nodes; `Option<T>` wires are the canonical
1323/// opt-in shape.
1324fn is_option_arg(ty: &Type) -> bool {
1325    let syn::Type::Path(p) = ty else {
1326        return false;
1327    };
1328    let Some(last) = p.path.segments.last() else {
1329        return false;
1330    };
1331    if last.ident != "Option" {
1332        return false;
1333    }
1334    matches!(&last.arguments,
1335        syn::PathArguments::AngleBracketed(args)
1336            if args.args.iter().any(|a| matches!(a, syn::GenericArgument::Type(_))))
1337}
1338
1339/// Borrow-shape detection for SRD-80b Wire cutover. The macro
1340/// dispatches owned types through `<T as Wire>::extract` / `::inject`;
1341/// borrow shapes are recognised syntactically and emitted as
1342/// direct `match`-on-`Value` extraction at the eval call site.
1343/// This keeps the [`Wire`] trait bound at `Sized + 'static` without
1344/// needing lifetime parameters.
1345///
1346/// Returns the matched `Value::<Variant>(inner)` pattern and the
1347/// accessor expression that yields the body's expected borrow.
1348#[derive(Clone)]
1349enum BorrowWire {
1350    /// `&str`  → `Value::Str(arc)` → `arc.as_ref()` (`&str`).
1351    Str,
1352    /// `&[u8]` → `Value::Bytes(arc)` → `arc.as_ref()` (`&[u8]`).
1353    Bytes,
1354    /// `&serde_json::Value` → `Value::Json(j)` → `j.as_ref()`.
1355    Json,
1356    /// `&[T]` for T in {f32, i32, f64, i64, f16, i16} — typed
1357    /// vector borrow. Variant tracked separately so we can emit
1358    /// the right `Value::Vec*` arm; element type is recovered
1359    /// from the syntactic recognition.
1360    Vec(
1361        &'static str, /* variant name */
1362        TokenStream2, /* PortType expr */
1363    ),
1364}
1365
1366/// `Ext<T>` for some `T`: an extension value that rides a `Ref2`
1367/// pair into step-owned scratch (SRD 115) and reaches the body
1368/// through `Wire::extract`. The generic path already handles it on
1369/// the interpreter; this recognizer lets the slot kit carry it too.
1370fn is_ext_wire(ty: &Type) -> bool {
1371    let s = type_to_string(ty);
1372    s.starts_with("Ext <") || s.contains(":: Ext <")
1373}
1374
1375fn is_borrow_wire_shape(ty: &Type) -> Option<BorrowWire> {
1376    let syn::Type::Reference(r) = ty else {
1377        return None;
1378    };
1379    if r.mutability.is_some() {
1380        return None;
1381    }
1382    match &*r.elem {
1383        // `&str`
1384        syn::Type::Path(p) if p.path.is_ident("str") => Some(BorrowWire::Str),
1385        // `&[T]` — bytes (T=u8) and typed vectors.
1386        syn::Type::Slice(slc) => {
1387            if let syn::Type::Path(p) = &*slc.elem {
1388                if p.path.is_ident("u8") {
1389                    return Some(BorrowWire::Bytes);
1390                }
1391                let elem_name = p.path.segments.last()?.ident.to_string();
1392                let (variant, port_expr) = match elem_name.as_str() {
1393                    "f32" => ("VecF32", quote!(polydat::ast::PortType::VecF32)),
1394                    "i32" => ("VecI32", quote!(polydat::ast::PortType::VecI32)),
1395                    "f64" => ("VecF64", quote!(polydat::ast::PortType::VecF64)),
1396                    "i64" => ("VecI64", quote!(polydat::ast::PortType::VecI64)),
1397                    "f16" => ("VecF16", quote!(polydat::ast::PortType::VecF16)),
1398                    "i16" => ("VecI16", quote!(polydat::ast::PortType::VecI16)),
1399                    "i8" => ("VecI8", quote!(polydat::ast::PortType::VecI8)),
1400                    _ => return None,
1401                };
1402                return Some(BorrowWire::Vec(variant, port_expr));
1403            }
1404            None
1405        }
1406        // `&serde_json::Value` — recognise by last segment `Value`
1407        // alongside `serde_json` somewhere in the path.
1408        syn::Type::Path(p)
1409            if last_segment_is(p, "Value") && path_contains_segment(p, "serde_json") =>
1410        {
1411            Some(BorrowWire::Json)
1412        }
1413        _ => None,
1414    }
1415}
1416
1417/// Token stream for extracting a borrow-shape wire from
1418/// `&inputs[idx]`. The macro emits this directly (no trait
1419/// dispatch) so the borrow's lifetime is bound to the eval
1420/// call's `&inputs` borrow naturally — no `unsafe transmute`.
1421fn borrow_extract_tokens(shape: BorrowWire, input_expr: TokenStream2) -> TokenStream2 {
1422    match shape {
1423        BorrowWire::Str => quote! {
1424            match #input_expr {
1425                polydat::ast::Value::Str(__arc) => __arc.as_ref(),
1426                __other => panic!("expected Str wire, got {__other:?}"),
1427            }
1428        },
1429        BorrowWire::Bytes => quote! {
1430            match #input_expr {
1431                polydat::ast::Value::Bytes(__arc) => __arc.as_ref(),
1432                __other => panic!("expected Bytes wire, got {__other:?}"),
1433            }
1434        },
1435        BorrowWire::Json => quote! {
1436            match #input_expr {
1437                polydat::ast::Value::Json(__arc) => __arc.as_ref(),
1438                __other => panic!("expected Json wire, got {__other:?}"),
1439            }
1440        },
1441        BorrowWire::Vec(variant, _port) => {
1442            let v = syn::Ident::new(variant, proc_macro2::Span::call_site());
1443            quote! {
1444                match #input_expr {
1445                    polydat::ast::Value::#v(__arc) => __arc.as_slice(),
1446                    __other => panic!(
1447                        concat!("expected ", stringify!(#v), " wire, got {:?}"),
1448                        __other),
1449                }
1450            }
1451        }
1452    }
1453}
1454
1455/// Token stream for the static `PortType` of a borrow-shape wire.
1456fn borrow_port_type(shape: &BorrowWire) -> TokenStream2 {
1457    match shape {
1458        BorrowWire::Str => quote!(polydat::ast::PortType::Str),
1459        BorrowWire::Bytes => quote!(polydat::ast::PortType::Bytes),
1460        BorrowWire::Json => quote!(polydat::ast::PortType::Json),
1461        BorrowWire::Vec(_, port_expr) => port_expr.clone(),
1462    }
1463}
1464
1465/// SRD-80 PR B.13 — typed-vector classifier. Recognises three
1466/// input shapes per element type: `SliceArc<T>`, `Vec<T>`,
1467/// `&[T]`. The element type's last path segment selects the
1468/// `WrapperWire::Vec*` variant.
1469fn classify_vec_wire(ty: &Type) -> Option<WrapperWire> {
1470    // Extract the element type from whichever of the three shapes
1471    // matches; none matching means this is not a typed vector.
1472    let elem: Type = strip_vec(ty)
1473        .or_else(|| strip_slice_arc(ty))
1474        .or_else(|| strip_borrowed_slice(ty))?
1475        .clone();
1476
1477    let syn::Type::Path(p) = &elem else {
1478        return None;
1479    };
1480    let last = p.path.segments.last()?;
1481    // f16 lives in the `half` crate, so the element path can
1482    // be `f16`, `half::f16`, etc. — match by last segment.
1483    match last.ident.to_string().as_str() {
1484        "f32" => Some(WrapperWire::VecF32),
1485        "i32" => Some(WrapperWire::VecI32),
1486        "f64" => Some(WrapperWire::VecF64),
1487        "i64" => Some(WrapperWire::VecI64),
1488        "f16" => Some(WrapperWire::VecF16),
1489        "i16" => Some(WrapperWire::VecI16),
1490        "i8" => Some(WrapperWire::VecI8),
1491        _ => None,
1492    }
1493}
1494
1495fn strip_vec(ty: &Type) -> Option<&Type> {
1496    let syn::Type::Path(p) = ty else {
1497        return None;
1498    };
1499    let last = p.path.segments.last()?;
1500    if last.ident != "Vec" {
1501        return None;
1502    }
1503    let syn::PathArguments::AngleBracketed(args) = &last.arguments else {
1504        return None;
1505    };
1506    args.args.iter().find_map(|a| match a {
1507        syn::GenericArgument::Type(t) => Some(t),
1508        _ => None,
1509    })
1510}
1511
1512fn strip_slice_arc(ty: &Type) -> Option<&Type> {
1513    let syn::Type::Path(p) = ty else {
1514        return None;
1515    };
1516    let last = p.path.segments.last()?;
1517    if last.ident != "SliceArc" {
1518        return None;
1519    }
1520    let syn::PathArguments::AngleBracketed(args) = &last.arguments else {
1521        return None;
1522    };
1523    args.args.iter().find_map(|a| match a {
1524        syn::GenericArgument::Type(t) => Some(t),
1525        _ => None,
1526    })
1527}
1528
1529fn strip_borrowed_slice(ty: &Type) -> Option<&Type> {
1530    let syn::Type::Reference(r) = ty else {
1531        return None;
1532    };
1533    if r.mutability.is_some() {
1534        return None;
1535    }
1536    let syn::Type::Slice(slc) = &*r.elem else {
1537        return None;
1538    };
1539    Some(&slc.elem)
1540}
1541
1542/// Detect `&[T]` (variadic) in arg-type position. SRD-80 PR B.9.
1543/// Returns the recognised element type for the supported primitive
1544/// element set; `None` otherwise (bare reference, non-slice, or
1545/// unsupported element type). Structural match — works regardless
1546/// of how the inner type is written (`Value` / `polydat::ast::Value`).
1547fn classify_variadic(ty: &Type) -> Option<VariadicElement> {
1548    let syn::Type::Reference(r) = ty else {
1549        return None;
1550    };
1551    if r.mutability.is_some() {
1552        return None;
1553    }
1554    let syn::Type::Slice(s) = &*r.elem else {
1555        return None;
1556    };
1557
1558    // `&[&str]` — element is a Type::Reference to a path "str".
1559    if let syn::Type::Reference(inner_r) = &*s.elem
1560        && inner_r.mutability.is_none()
1561        && let syn::Type::Path(p) = &*inner_r.elem
1562        && p.path.is_ident("str")
1563    {
1564        return Some(VariadicElement::BorrowedStr);
1565    }
1566
1567    // Bare-path element types — match by last path segment ident.
1568    let syn::Type::Path(p) = &*s.elem else {
1569        return None;
1570    };
1571    let last = p.path.segments.last()?;
1572    if !last.arguments.is_empty() {
1573        return None;
1574    }
1575    match last.ident.to_string().as_str() {
1576        "u64" => Some(VariadicElement::U64),
1577        // NOTE: `&[f64]` is deliberately NOT variadic — it is the
1578        // `VecF64` vector wire, uniform with every other lane
1579        // element (`&[f32]`/`&[i32]`/…). A variadic run of f64
1580        // wires would need an explicit `Variadic<f64>` spelling.
1581        "bool" => Some(VariadicElement::Bool),
1582        "String" => Some(VariadicElement::OwnedString),
1583        "Value" => Some(VariadicElement::Value),
1584        _ => None,
1585    }
1586}
1587
1588/// SRD-80b Phase 5 S16 — detect `Result<T, E>` return type for
1589/// fallible-construction nodes. Returns `Some(T)` (the Ok type)
1590/// when the return is a `Result<T, _>`; `None` otherwise. Matches
1591/// any path ending in `Result` so both bare `Result` and fully
1592/// qualified `std::result::Result` work.
1593///
1594/// The Err arm is consumed for its `Into<String>` projection at
1595/// emission time, so we don't pin its shape here — any E that
1596/// satisfies `Into<String>` (including `String` itself) is fine.
1597fn classify_result_return(ty: &Type) -> Option<Type> {
1598    let syn::Type::Path(p) = ty else {
1599        return None;
1600    };
1601    let last = p.path.segments.last()?;
1602    if last.ident != "Result" {
1603        return None;
1604    }
1605    let syn::PathArguments::AngleBracketed(args) = &last.arguments else {
1606        return None;
1607    };
1608    // Two args expected: <Ok, Err>. Tolerate `Result<T>` (rare alias)
1609    // by requiring at least one type arg.
1610    let mut tys = args.args.iter().filter_map(|a| match a {
1611        syn::GenericArgument::Type(t) => Some(t.clone()),
1612        _ => None,
1613    });
1614    tys.next()
1615}
1616
1617fn generate(func: ItemFn, attrs: NodeAttrs) -> syn::Result<TokenStream2> {
1618    let fn_name = &func.sig.ident;
1619    // SRD-80 PR B.7: strip `r#` from raw identifiers (`fn r#mod`,
1620    // `fn r#type`, etc.) so the Rust struct name comes out clean.
1621    let fn_name_raw = fn_name.to_string();
1622    let rust_name_str = fn_name_raw
1623        .strip_prefix("r#")
1624        .unwrap_or(&fn_name_raw)
1625        .to_string();
1626    let struct_name = attrs
1627        .struct_name
1628        .clone()
1629        .unwrap_or_else(|| format_ident!("{}", to_camel_case(&rust_name_str)));
1630    let func_name_str = rust_name_str.clone();
1631    let category = &attrs.category;
1632
1633    // Classify each function arg: wire or const? Reject any
1634    // unsupported pattern (self, complex destructuring, bare-
1635    // type wires the macro doesn't recognize).
1636    let mut args: Vec<ClassifiedArg> = Vec::new();
1637    for input in &func.sig.inputs {
1638        match input {
1639            FnArg::Receiver(r) => {
1640                return Err(syn::Error::new_spanned(
1641                    r,
1642                    "#[polydat_node] does not support `self` parameters yet; \
1643                     state-bearing nodes are deferred to a later PR.",
1644                ));
1645            }
1646            FnArg::Typed(pat_ty) => {
1647                let ident = match &*pat_ty.pat {
1648                    Pat::Ident(p) => p.ident.clone(),
1649                    other => {
1650                        return Err(syn::Error::new_spanned(
1651                            other,
1652                            "#[polydat_node] requires plain identifier parameters; \
1653                             pattern matching in argument position isn't supported.",
1654                        ));
1655                    }
1656                };
1657                let declared_ty = (*pat_ty.ty).clone();
1658                let default_value = parse_poly_default(&pat_ty.attrs)?;
1659                let setup_attr = parse_poly_const(&pat_ty.attrs)?;
1660                let wire_constraint = parse_wire_constraint(&pat_ty.attrs)?;
1661                let is_polywire = classify_polywire(&declared_ty);
1662                let variadic_elem = classify_variadic(&declared_ty);
1663
1664                let kind = if let Some(elem) = variadic_elem {
1665                    if default_value.is_some() || setup_attr.is_some() || is_polywire {
1666                        return Err(syn::Error::new_spanned(
1667                            pat_ty,
1668                            "variadic `&[T]` args don't combine with \
1669                             #[poly_default(...)], #[poly_const(...)], or `Value`.",
1670                        ));
1671                    }
1672                    ArgKind::Variadic(elem)
1673                } else if is_polywire {
1674                    if default_value.is_some() || setup_attr.is_some() {
1675                        return Err(syn::Error::new_spanned(
1676                            pat_ty,
1677                            "`Value` args (PolyWire) don't combine with \
1678                             #[poly_default(...)] or #[poly_const(...)]; \
1679                             the runtime port type comes from the upstream wire \
1680                             at construction time.",
1681                        ));
1682                    }
1683                    ArgKind::PolyWire
1684                } else if let Some((setup_fn, source_args)) = setup_attr {
1685                    // `#[poly_const(...)]` requires `&T` arg type.
1686                    let inner_ty = classify_borrowed(&declared_ty).ok_or_else(|| {
1687                        syn::Error::new_spanned(
1688                            &declared_ty,
1689                            "#[poly_const(...)] requires the argument type to be \
1690                             a borrow `&T` — the macro stores the computed `T` \
1691                             in a struct field and hands the body a borrow each \
1692                             eval.",
1693                        )
1694                    })?;
1695                    if default_value.is_some() {
1696                        return Err(syn::Error::new_spanned(
1697                            pat_ty,
1698                            "#[poly_default(...)] cannot combine with \
1699                             #[poly_const(...)]; defaults belong on the source \
1700                             Const arg, not on the derived setup arg.",
1701                        ));
1702                    }
1703                    ArgKind::Setup(Box::new(SetupSpec {
1704                        inner_ty,
1705                        setup_fn,
1706                        source_args,
1707                    }))
1708                } else if let Some(inner) = classify_const_vec(&declared_ty) {
1709                    // SRD-80b Phase C — `Const<Vec<C>>` variadic
1710                    // workload-list. `poly_default` doesn't apply
1711                    // (the empty list IS the default); other
1712                    // attributes don't compose.
1713                    if default_value.is_some() {
1714                        return Err(syn::Error::new_spanned(
1715                            pat_ty,
1716                            "#[poly_default(...)] cannot combine with \
1717                             `Const<Vec<C>>`; the empty Vec IS the implicit \
1718                             default. Use `Const<C>` with a poly_default \
1719                             literal for a single-value default instead.",
1720                        ));
1721                    }
1722                    if setup_attr.is_some() {
1723                        return Err(syn::Error::new_spanned(
1724                            pat_ty,
1725                            "`Const<Vec<C>>` doesn't combine with \
1726                             #[poly_const(...)]; route the derived state \
1727                             from a scalar `Const<C>` source instead.",
1728                        ));
1729                    }
1730                    ArgKind::ConstVec(inner)
1731                } else {
1732                    match classify_type(&declared_ty) {
1733                        Some(shape) => ArgKind::Const(shape),
1734                        None => {
1735                            if default_value.is_some() {
1736                                return Err(syn::Error::new_spanned(
1737                                    pat_ty,
1738                                    "#[poly_default(...)] only applies to const args \
1739                                     (`Const<T>`); bare-type wire args don't have \
1740                                     assembly-time defaults.",
1741                                ));
1742                            }
1743                            ArgKind::Wire
1744                        }
1745                    }
1746                };
1747                args.push(ClassifiedArg {
1748                    name: ident,
1749                    declared_ty,
1750                    kind,
1751                    default_value,
1752                    wire_constraint,
1753                });
1754            }
1755        }
1756    }
1757
1758    // SRD-80b Phase C — `Const<Vec<C>>` consumes the tail of
1759    // `consts[..]` at build time, so at most one ConstVec arg is
1760    // allowed per node and it must be the last const arg in
1761    // declaration order. Validate before emission.
1762    {
1763        let const_vec_positions: Vec<usize> = args
1764            .iter()
1765            .enumerate()
1766            .filter_map(|(i, a)| {
1767                if matches!(a.kind, ArgKind::ConstVec(_)) {
1768                    Some(i)
1769                } else {
1770                    None
1771                }
1772            })
1773            .collect();
1774        if const_vec_positions.len() > 1 {
1775            return Err(syn::Error::new_spanned(
1776                &args[const_vec_positions[1]].declared_ty,
1777                "#[polydat_node] supports at most one `Const<Vec<C>>` arg \
1778                 per function; the variadic-const surface consumes the \
1779                 tail of the consts slice and a second one would have no \
1780                 entries to claim.",
1781            ));
1782        }
1783        if let Some(&pos) = const_vec_positions.first() {
1784            // Any Const(_) declared AFTER the ConstVec would never
1785            // bind (its index ≥ ConstVec's tail-start).
1786            for later in &args[pos + 1..] {
1787                if matches!(later.kind, ArgKind::Const(_)) {
1788                    return Err(syn::Error::new_spanned(
1789                        &later.declared_ty,
1790                        "scalar `Const<T>` arg declared after a \
1791                         `Const<Vec<C>>` arg is unreachable — the variadic \
1792                         consumes everything from its position to the end \
1793                         of the consts slice. Move the scalar consts BEFORE \
1794                         the `Const<Vec<C>>` in the function signature.",
1795                    ));
1796                }
1797            }
1798        }
1799    }
1800
1801    // Map a bare wire-arg type to a PortType expression.
1802    //
1803    // SRD-80b: the canonical answer is `<#ty as Wire>::PORT` —
1804    // any owned type that impls [`Wire`] is admitted, and adding
1805    // a new wire type means adding one Wire impl (no macro
1806    // source change). Three exceptions stay structural because
1807    // they can't be expressed through the trait:
1808    //
1809    //   1. Borrow shapes (`&str`, `&[u8]`, `&[T]`,
1810    //      `&serde_json::Value`) — `Wire` is `Sized + 'static`
1811    //      so borrowed refs can't impl it. The macro emits the
1812    //      literal `PortType` here and direct `match`-on-`Value`
1813    //      extraction elsewhere.
1814    //
1815    //   2. `Arc<T>` Handle (non-special T) — would conflict with
1816    //      the concrete `Arc<[u8]>` / `Arc<serde_json::Value>`
1817    //      impls if expressed as a blanket. Kept as inline
1818    //      downcast at the extract site; port type is the static
1819    //      `Handle`.
1820    //
1821    //   3. PolyWire (`Value`-typed wire) — polymorphic at
1822    //      runtime; no static `PortType`. The `ArgKind::PolyWire`
1823    //      path handles this independently of `wire_port_type_for`.
1824    //
1825    // Everything else — including `Option<T>`, `Ext<T>`, and any
1826    // future combinator added by impl'ing `Wire` — flows through
1827    // trait dispatch.
1828    let wire_port_type_for = |ty: &Type| -> syn::Result<TokenStream2> {
1829        if let Some(kind) = classify_wrapper_wire(ty) {
1830            return Ok(match kind {
1831                WrapperWire::Bytes => quote!(polydat::ast::PortType::Bytes),
1832                WrapperWire::Json => quote!(polydat::ast::PortType::Json),
1833                WrapperWire::Handle => quote!(polydat::ast::PortType::Handle),
1834                WrapperWire::VecF32 => quote!(polydat::ast::PortType::VecF32),
1835                WrapperWire::VecI32 => quote!(polydat::ast::PortType::VecI32),
1836                WrapperWire::VecF64 => quote!(polydat::ast::PortType::VecF64),
1837                WrapperWire::VecI64 => quote!(polydat::ast::PortType::VecI64),
1838                WrapperWire::VecF16 => quote!(polydat::ast::PortType::VecF16),
1839                WrapperWire::VecI16 => quote!(polydat::ast::PortType::VecI16),
1840                WrapperWire::VecI8 => quote!(polydat::ast::PortType::VecI8),
1841            });
1842        }
1843        if let Some(borrow) = is_borrow_wire_shape(ty) {
1844            return Ok(borrow_port_type(&borrow));
1845        }
1846        // Fall through to trait dispatch — `<T as Wire>::PORT` is
1847        // a const associated, evaluable at codegen time. Types
1848        // without a `Wire` impl produce a clean E0277 at the
1849        // function's call site, naming the missing trait bound.
1850        Ok(quote!(<#ty as polydat::derive_support::Wire>::PORT))
1851    };
1852
1853    // Build the NodeMeta `ins` slot list — one entry per arg,
1854    // dispatched by kind. Wire args get `Slot::Wire(...)`;
1855    // const args get `Slot::Const { ... }` populated with the
1856    // captured field value at construction time.
1857    let mut slot_exprs: Vec<TokenStream2> = Vec::new();
1858    for a in &args {
1859        let name_str = a.name.to_string();
1860        match &a.kind {
1861            ArgKind::Wire => {
1862                let pt = wire_port_type_for(&a.declared_ty)?;
1863                let ty = &a.declared_ty;
1864                // SRD-80 PR B.14: optional `#[constraint(Variant)]`.
1865                let constraint_chain = if let Some(variant) = &a.wire_constraint {
1866                    quote! {
1867                        .with_constraint(
1868                            polydat::dsl::const_constraints::ConstConstraint::#variant)
1869                    }
1870                } else {
1871                    quote!()
1872                };
1873                // SRD-80b in-spirit — `Wire::WIRE_COST` is read
1874                // from the trait at codegen. Owned/non-borrow
1875                // wire types route here; borrow shapes don't
1876                // impl Wire so they get the default Data cost
1877                // (the WireCost::Config opt-in only applies to
1878                // owned types wrapped in `Config<T>`).
1879                let cost_chain = if is_borrow_wire_shape(ty).is_none()
1880                    && classify_wrapper_wire(ty) != Some(WrapperWire::Handle)
1881                {
1882                    quote! {
1883                        .with_cost(<#ty as polydat::derive_support::Wire>::WIRE_COST)
1884                    }
1885                } else {
1886                    quote!()
1887                };
1888                slot_exprs.push(quote! {
1889                    polydat::ast::Slot::Wire(
1890                        polydat::ast::Port::new(#name_str, #pt)
1891                            #constraint_chain
1892                            #cost_chain
1893                    )
1894                });
1895            }
1896            ArgKind::Const(shape) => {
1897                let field_name = &a.name;
1898                let const_value_ctor = match shape {
1899                    ConstShape::U64 => quote!(polydat::ast::ConstValue::U64(#field_name)),
1900                    ConstShape::F64 => quote!(polydat::ast::ConstValue::F64(#field_name)),
1901                    ConstShape::Bool => {
1902                        quote!(polydat::ast::ConstValue::U64(if #field_name { 1 } else { 0 }))
1903                    }
1904                    ConstShape::Str => quote!(polydat::ast::ConstValue::Str(#field_name.clone())),
1905                };
1906                slot_exprs.push(quote! {
1907                    polydat::ast::Slot::Const {
1908                        name: #name_str.into(),
1909                        value: #const_value_ctor,
1910                    }
1911                });
1912            }
1913            ArgKind::Setup(_) => {
1914                // Setup args don't appear in NodeMeta.ins —
1915                // they're derived state, not declared params.
1916                // The source Const arg already carries the
1917                // introspectable value.
1918            }
1919            ArgKind::PolyWire => {
1920                // Port type is the `<argname>_type` parameter
1921                // passed to `new()`; the variable is in scope
1922                // because the macro emits it as a `new()` param.
1923                let pt_param = format_ident!("{}_type", a.name);
1924                slot_exprs.push(quote! {
1925                    polydat::ast::Slot::Wire(polydat::ast::Port::new(
1926                        #name_str, #pt_param))
1927                });
1928            }
1929            ArgKind::Variadic(_) => {
1930                // Variadic emits per-element slots at construction.
1931                // The macro generates `extend` into the slot vec
1932                // from a 0..n_wires loop. Each slot is named
1933                // `<argname>_<i>` to keep the meta diff-friendly.
1934                // (Handled in the new() body via a separate pass —
1935                // see `variadic_slot_extends` below.)
1936            }
1937            ArgKind::ConstVec(inner) => {
1938                // SRD-80b — `Const<Vec<C>>` emits a `Slot::Const`
1939                // entry when the inner element has a matching
1940                // `ConstValue::Vec*` variant (u64, f64). This
1941                // makes the captured list visible to JIT slot-
1942                // walkers and introspection (`jit_constants_from_slots`).
1943                // For element types without a parallel
1944                // `ConstValue` variant (bool, Str), no slot is
1945                // emitted; the FuncSig's `Arity::VariadicConsts`
1946                // tracks the surface and the stored Vec<C> field
1947                // is the canonical storage.
1948                let field_name = &a.name;
1949                match inner {
1950                    ConstShape::U64 => slot_exprs.push(quote! {
1951                        polydat::ast::Slot::Const {
1952                            name: #name_str.into(),
1953                            value: polydat::ast::ConstValue::VecU64(#field_name.clone()),
1954                        }
1955                    }),
1956                    ConstShape::F64 => slot_exprs.push(quote! {
1957                        polydat::ast::Slot::Const {
1958                            name: #name_str.into(),
1959                            value: polydat::ast::ConstValue::VecF64(#field_name.clone()),
1960                        }
1961                    }),
1962                    _ => {}
1963                }
1964            }
1965        }
1966    }
1967    // For each variadic arg, also emit a runtime loop that
1968    // appends N slots to the `Slot` vec.
1969    let variadic_slot_extends: Vec<TokenStream2> = args
1970        .iter()
1971        .filter_map(|a| match &a.kind {
1972            ArgKind::Variadic(elem) => {
1973                let name_str = a.name.to_string();
1974                let pt = elem.port_type_tokens();
1975                Some(quote! {
1976                    for __i in 0..n_wires {
1977                        ins.push(polydat::ast::Slot::Wire(
1978                            polydat::ast::Port::new(
1979                                format!("{}_{__i}", #name_str),
1980                                #pt,
1981                            )));
1982                    }
1983                })
1984            }
1985            _ => None,
1986        })
1987        .collect();
1988
1989    // Build the FuncSig.params static slice — one ParamSpec
1990    // per declared arg (Wire and Const). Setup args don't
1991    // appear in the FuncSig surface — they're macro-internal
1992    // derived state.
1993    let param_specs: Vec<TokenStream2> = args
1994        .iter()
1995        .filter_map(|a| {
1996            let name_str = a.name.to_string();
1997            // Variadic args declare `required: false` — they accept
1998            // any count from `variadic_min` (default 0) upward.
1999            // `ConstVec` follows the same pattern (empty is valid).
2000            let required = match &a.kind {
2001                ArgKind::Variadic(_) | ArgKind::ConstVec(_) => false,
2002                _ => a.default_value.is_none(),
2003            };
2004            let slot_type = match &a.kind {
2005                ArgKind::Wire | ArgKind::PolyWire | ArgKind::Variadic(_) => {
2006                    quote!(polydat::ast::SlotType::Wire)
2007                }
2008                ArgKind::Const(shape) => shape.slot_type_tokens(),
2009                ArgKind::ConstVec(inner) => inner.slot_type_tokens(),
2010                ArgKind::Setup(_) => return None,
2011            };
2012            Some(quote! {
2013                polydat::dsl::registry::ParamSpec {
2014                    name: #name_str,
2015                    slot_type: #slot_type,
2016                    required: #required,
2017                    example: #name_str,
2018                    constraint: None,
2019                }
2020            })
2021        })
2022        .collect();
2023
2024    // Output type. The simple case requires a concrete return
2025    // type (-> T); unit / unspecified isn't supported.
2026    let declared_ret_ty = match &func.sig.output {
2027        ReturnType::Default => {
2028            return Err(syn::Error::new_spanned(
2029                &func.sig,
2030                "#[polydat_node] requires an explicit return type; \
2031                 nodes always produce a value.",
2032            ));
2033        }
2034        ReturnType::Type(_, t) => (**t).clone(),
2035    };
2036    // SRD-80b Phase 5 S16 — fallible construction. When the body
2037    // returns `Result<T, E>`, the macro treats T as the effective
2038    // node-output type and emits a `try_new(...) -> Result<Self,
2039    // String>` constructor that runs the body once at
2040    // construction, caches the Ok value, and propagates Err. Only
2041    // valid for nodes with no wire/polywire inputs — the body has
2042    // to be fully resolvable at construction.
2043    let fallible_inner_ty: Option<Type> = classify_result_return(&declared_ret_ty);
2044    let is_fallible = fallible_inner_ty.is_some();
2045    let ret_ty = fallible_inner_ty
2046        .clone()
2047        .unwrap_or_else(|| declared_ret_ty.clone());
2048    let ret_is_polywire = classify_polywire(&ret_ty);
2049
2050    if is_fallible {
2051        // Wire / polywire / variadic inputs are not supported in
2052        // fallible mode: the body executes once at construction,
2053        // not per-eval. Const args are fine — they're all known
2054        // by the time `try_new` runs.
2055        for a in &args {
2056            match &a.kind {
2057                ArgKind::Wire | ArgKind::PolyWire | ArgKind::Variadic(_) => {
2058                    return Err(syn::Error::new_spanned(
2059                        &a.declared_ty,
2060                        "fallible-construction nodes (-> Result<T, E>) must \
2061                         have only Const args. Wire/PolyWire/variadic inputs \
2062                         can't be evaluated at construction time. Use the \
2063                         #[poly_const(setup_fn, from = ...)] shape instead \
2064                         when per-eval inputs are needed.",
2065                    ));
2066                }
2067                ArgKind::Setup(_) | ArgKind::Const(_) | ArgKind::ConstVec(_) => {}
2068            }
2069        }
2070    }
2071
2072    // SRD-80 PR B.10: detect tuple-typed return for multi-output.
2073    let tuple_ret_elems: Option<Vec<Type>> = match &ret_ty {
2074        syn::Type::Tuple(t) => Some(t.elems.iter().cloned().collect()),
2075        _ => None,
2076    };
2077
2078    // SRD-80b dynamic-output shape — detect `DynamicOutputs<T>`
2079    // return and locate the `Const<Vec<C>>` arg whose length
2080    // drives the output port count at construction.
2081    let dynamic_outputs_inner: Option<Type> = classify_dynamic_outputs(&ret_ty);
2082    let dynamic_outputs_count_arg: Option<syn::Ident> = if dynamic_outputs_inner.is_some() {
2083        let const_vec_args: Vec<&syn::Ident> = args
2084            .iter()
2085            .filter_map(|a| match &a.kind {
2086                ArgKind::ConstVec(_) => Some(&a.name),
2087                _ => None,
2088            })
2089            .collect();
2090        if const_vec_args.len() != 1 {
2091            return Err(syn::Error::new_spanned(
2092                &ret_ty,
2093                format!(
2094                    "`DynamicOutputs<T>` return requires exactly one \
2095                     `Const<Vec<C>>` arg to drive the output port count \
2096                     (got {}). Declare one `Const<Vec<C>>` arg whose length \
2097                     determines the number of output ports.",
2098                    const_vec_args.len(),
2099                ),
2100            ));
2101        }
2102        Some(const_vec_args[0].clone())
2103    } else {
2104        None
2105    };
2106
2107    if tuple_ret_elems.is_some() && ret_is_polywire {
2108        // Type::Tuple isn't Type::Path so this is impossible, but
2109        // belt-and-suspenders for future return-shape changes.
2110        return Err(syn::Error::new_spanned(
2111            &ret_ty,
2112            "tuple return + PolyWire don't compose (SameAsInput is a \
2113             single-output dispatch).",
2114        ));
2115    }
2116
2117    // SRD-80 PR B.8: when the return type is `Value`, the
2118    // output port type tracks the first PolyWire arg's runtime
2119    // port type (SameAsInput). Otherwise it's the primitive's
2120    // fixed PortType.
2121    let first_polywire_idx: Option<usize> = args
2122        .iter()
2123        .enumerate()
2124        .find(|(_, a)| matches!(a.kind, ArgKind::PolyWire))
2125        .map(|(i, _)| i);
2126
2127    // Per-output port-type token streams, indexed positionally.
2128    // Single-output → 1-element vec; tuple → N elements.
2129    let output_port_types: Vec<TokenStream2> = if let Some(elems) = &tuple_ret_elems {
2130        elems
2131            .iter()
2132            .map(wire_port_type_for)
2133            .collect::<syn::Result<Vec<_>>>()?
2134    } else if ret_is_polywire {
2135        // Prefer a singleton PolyWire arg for SameAsInput
2136        // dispatch; fall back to a variadic `&[Value]` arg
2137        // (split-halves shape) whose runtime element types
2138        // drive the output polymorphism. The static slot
2139        // gets a `PortType::U64` placeholder (assembler skips
2140        // type-check for these); eval enforces uniformity.
2141        if let Some(polywire_arg) = args.iter().find(|a| matches!(a.kind, ArgKind::PolyWire)) {
2142            let pt_ident = format_ident!("{}_type", polywire_arg.name);
2143            vec![quote!(#pt_ident)]
2144        } else if args
2145            .iter()
2146            .any(|a| matches!(&a.kind, ArgKind::Variadic(VariadicElement::Value)))
2147        {
2148            vec![quote!(polydat::ast::PortType::U64)]
2149        } else {
2150            return Err(syn::Error::new_spanned(
2151                &ret_ty,
2152                "function returns `Value` but has no `Value` arg — the macro \
2153                 needs at least one PolyWire (`Value`) arg or a `&[Value]` \
2154                 variadic to source the runtime port type for the output.",
2155            ));
2156        }
2157    } else if let Some(inner) = &dynamic_outputs_inner {
2158        // Single per-element port type for the dynamic case.
2159        // The count is determined at construction time; this
2160        // entry is used by the codegen as the port type each
2161        // output port carries.
2162        vec![wire_port_type_for(inner)?]
2163    } else {
2164        vec![wire_port_type_for(&ret_ty)?]
2165    };
2166
2167    // SRD-80 PR B.10: output names. Operator-supplied via
2168    // `output_names(a, b, c)`; falls back to `out_0`, `out_1`, ...
2169    // for tuple returns; just "output" for single returns.
2170    let output_names_strs: Vec<String> = match (&tuple_ret_elems, &attrs.output_names) {
2171        (Some(elems), Some(names)) => {
2172            if names.len() != elems.len() {
2173                return Err(syn::Error::new_spanned(
2174                    &ret_ty,
2175                    format!(
2176                        "tuple return has {} elements but `output_names(...)` \
2177                         lists {}; lengths must match.",
2178                        elems.len(),
2179                        names.len(),
2180                    ),
2181                ));
2182            }
2183            names.iter().map(|n| n.to_string()).collect()
2184        }
2185        (Some(elems), None) => (0..elems.len()).map(|i| format!("out_{i}")).collect(),
2186        (None, Some(names)) if names.len() != 1 => {
2187            return Err(syn::Error::new_spanned(
2188                &ret_ty,
2189                "single-output return doesn't accept multi-name `output_names(...)`.",
2190            ));
2191        }
2192        (None, Some(names)) => vec![names[0].to_string()],
2193        (None, None) => vec!["output".to_string()],
2194    };
2195
2196    // FuncSig::output_port — the statically-known return port for
2197    // single fixed-output nodes; None for tuple / polymorphic /
2198    // dynamic shapes (the DSL type inference then falls back to
2199    // its heuristic).
2200    let output_port_field: TokenStream2 =
2201        if tuple_ret_elems.is_some() || ret_is_polywire || dynamic_outputs_inner.is_some() {
2202            quote!(None)
2203        } else {
2204            let pt = &output_port_types[0];
2205            quote!(Some(#pt))
2206        };
2207
2208    let output_count = if dynamic_outputs_inner.is_some() {
2209        0
2210    } else {
2211        output_port_types.len()
2212    };
2213    // SRD-80b: `0` in the FuncSig signals "dynamic, determined at
2214    // compile time" (existing FuncSig convention from the doc).
2215    let output_count_lit =
2216        syn::LitInt::new(&output_count.to_string(), proc_macro2::Span::call_site());
2217
2218    // When return is `Value`, prefer SameAsInput dispatch
2219    // against a singleton PolyWire arg; for the split-halves
2220    // `&[Value]` case there's no singleton to point at, so
2221    // fall back to OutputType::Fixed (the static slot's
2222    // placeholder PortType is used and eval enforces type
2223    // uniformity).
2224    let output_type_tokens: TokenStream2 = match (ret_is_polywire, first_polywire_idx) {
2225        (true, Some(idx)) => {
2226            let i = syn::Index::from(idx);
2227            quote!(polydat::dsl::registry::OutputType::SameAsInput(#i))
2228        }
2229        _ => quote!(polydat::dsl::registry::OutputType::Fixed),
2230    };
2231
2232    // Struct fields. Wire/PolyWire/Variadic → no field (arity
2233    // reflected in `meta.ins.len()`); Const → owned-typed field;
2234    // ConstVec → Vec<inner>; Setup → field of the borrowed
2235    // inner type.
2236    let struct_fields: Vec<TokenStream2> = args
2237        .iter()
2238        .filter_map(|a| match &a.kind {
2239            ArgKind::Wire | ArgKind::PolyWire | ArgKind::Variadic(_) => None,
2240            ArgKind::Const(shape) => {
2241                let n = &a.name;
2242                let ft = shape.field_type_tokens();
2243                let doc = format!("The `{n}` argument, as given at construction.");
2244                Some(quote!(#[doc = #doc] pub #n: #ft))
2245            }
2246            ArgKind::ConstVec(inner) => {
2247                let n = &a.name;
2248                let ft = inner.field_type_tokens();
2249                let doc = format!("The `{n}` arguments, as given at construction.");
2250                Some(quote!(#[doc = #doc] pub #n: Vec<#ft>))
2251            }
2252            ArgKind::Setup(spec) => {
2253                let n = &a.name;
2254                let ty = &spec.inner_ty;
2255                let doc = format!("The `{n}` value, computed once at construction.");
2256                Some(quote!(#[doc = #doc] pub #n: #ty))
2257            }
2258        })
2259        .collect();
2260
2261    // `new(<polywire_types..>, <consts..>)` constructor params, in
2262    // declaration order. Const args contribute their owned-typed
2263    // value; PolyWire args contribute a `<argname>_type: PortType`
2264    // parameter that names the runtime port type the assembler
2265    // resolved for the upstream wire. Setup args are computed
2266    // inside new(), not parameters.
2267    let new_params: Vec<TokenStream2> = args
2268        .iter()
2269        .filter_map(|a| match &a.kind {
2270            ArgKind::Wire => None,
2271            ArgKind::Const(shape) => {
2272                let n = &a.name;
2273                let ft = shape.field_type_tokens();
2274                Some(quote!(#n: #ft))
2275            }
2276            ArgKind::ConstVec(inner) => {
2277                let n = &a.name;
2278                let ft = inner.field_type_tokens();
2279                Some(quote!(#n: Vec<#ft>))
2280            }
2281            ArgKind::Setup(_) => None,
2282            ArgKind::PolyWire => {
2283                let n = format_ident!("{}_type", a.name);
2284                Some(quote!(#n: polydat::ast::PortType))
2285            }
2286            // Variadic args don't add their OWN per-arg param —
2287            // the variadic-arity is supplied via a SINGLE
2288            // `n_wires: usize` parameter appended once at the end
2289            // (see `variadic_n_wires_param` below).
2290            ArgKind::Variadic(_) => None,
2291        })
2292        .collect();
2293
2294    // SRD-80 PR B.9: append a single `n_wires: usize` parameter
2295    // to `new()` when the function declares any variadic arg.
2296    // SRD-80b split-halves variadic: TWO variadics in succession
2297    // share a single `n_wires` param (interpreted as "count per
2298    // half"). The macro emits 2*n_wires wire slots and slices
2299    // the inputs at the midpoint at eval time. Used by `pick`'s
2300    // `(b0,...,bN,v0,...,vN)` workload syntax per SRD-66.
2301    let has_variadic = args.iter().any(|a| matches!(a.kind, ArgKind::Variadic(_)));
2302    let variadic_count = args
2303        .iter()
2304        .filter(|a| matches!(a.kind, ArgKind::Variadic(_)))
2305        .count();
2306    if variadic_count > 2 {
2307        return Err(syn::Error::new_spanned(
2308            &func.sig,
2309            "`#[polydat_node]` supports at most two variadic `&[T]` args (split-halves shape). \
2310             Functions declaring more than two are not expressible in any SRD-80b shape.",
2311        ));
2312    }
2313    let is_split_halves = variadic_count == 2;
2314    // Positional index of each Variadic arg in declaration
2315    // order, used by `arg_bindings` to slice `inputs` at the
2316    // midpoint in split-halves mode.
2317    let variadic_positions: std::collections::HashMap<String, usize> = args
2318        .iter()
2319        .filter(|a| matches!(a.kind, ArgKind::Variadic(_)))
2320        .enumerate()
2321        .map(|(i, a)| (a.name.to_string(), i))
2322        .collect();
2323    let new_params: Vec<TokenStream2> = if has_variadic {
2324        let mut v = new_params;
2325        v.push(quote!(n_wires: usize));
2326        v
2327    } else {
2328        new_params
2329    };
2330
2331    // Build a lookup from arg name → const-shape category so the
2332    // Setup pre-compute step can dispatch on the source's shape
2333    // to produce the right access expression.
2334    #[derive(Clone, Copy)]
2335    enum ConstSourceShape {
2336        /// Scalar `Const<u64>` / `Const<f64>` / `Const<bool>`.
2337        ScalarValue,
2338        /// `Const<&str>` / `Const<String>` — backing field is
2339        /// `String`; setup fn typically wants `&str`.
2340        ScalarStr,
2341        /// `Const<Vec<C>>` — backing field is `Vec<C>`; setup fn
2342        /// typically wants `&Vec<C>` or `&[C]`.
2343        VecValues,
2344    }
2345    let const_shape_by_name: std::collections::HashMap<String, ConstSourceShape> = args
2346        .iter()
2347        .filter_map(|a| match &a.kind {
2348            ArgKind::Const(ConstShape::Str) => {
2349                Some((a.name.to_string(), ConstSourceShape::ScalarStr))
2350            }
2351            ArgKind::Const(_) => Some((a.name.to_string(), ConstSourceShape::ScalarValue)),
2352            ArgKind::ConstVec(_) => Some((a.name.to_string(), ConstSourceShape::VecValues)),
2353            _ => None,
2354        })
2355        .collect();
2356
2357    // Setup pre-compute lines, emitted at the top of `new()`
2358    // BEFORE `Self { ... }` so they can borrow the const
2359    // locals before those values are moved into self.
2360    let setup_precomputes: Vec<TokenStream2> = args
2361        .iter()
2362        .filter_map(|a| match &a.kind {
2363            ArgKind::Wire
2364            | ArgKind::Const(_)
2365            | ArgKind::ConstVec(_)
2366            | ArgKind::PolyWire
2367            | ArgKind::Variadic(_) => None,
2368            ArgKind::Setup(spec) => {
2369                let n = &a.name;
2370                let setup_fn = &spec.setup_fn;
2371                // SRD-80b amendment — `source_args` may be empty
2372                // (session-static setup), single (the common
2373                // case), or multi (joint derivation). Per-source
2374                // access dispatch reads each named const's
2375                // shape and emits the right body-side expression.
2376                let mut src_exprs: Vec<TokenStream2> = Vec::new();
2377                let mut err: Option<TokenStream2> = None;
2378                for src in &spec.source_args {
2379                    let shape = const_shape_by_name.get(&src.to_string());
2380                    let expr = match shape {
2381                        Some(ConstSourceShape::ScalarStr) => quote!(#src.as_str()),
2382                        Some(ConstSourceShape::ScalarValue) => quote!(#src),
2383                        // ConstVec source: pass a borrow of the
2384                        // Vec. Setup fn signatures like
2385                        // `fn build(w: &Vec<f64>)` or
2386                        // `fn build(w: &[f64])` both work via
2387                        // Deref / unsized coercion.
2388                        Some(ConstSourceShape::VecValues) => quote!(&#src),
2389                        None => {
2390                            err = Some(
2391                                syn::Error::new(
2392                                    src.span(),
2393                                    format!(
2394                                        "#[poly_const(... from = ... {src} ...)] — \
2395                                     `{src}` is not declared as a `Const<T>` \
2396                                     arg in the same function signature."
2397                                    ),
2398                                )
2399                                .to_compile_error(),
2400                            );
2401                            break;
2402                        }
2403                    };
2404                    src_exprs.push(expr);
2405                }
2406                if let Some(e) = err {
2407                    return Some(e);
2408                }
2409                let call = quote!(#setup_fn( #( #src_exprs ),* ));
2410                Some(quote! {
2411                    let #n = #call;
2412                })
2413            }
2414        })
2415        .collect();
2416
2417    // Self { ... } field-init list. Const args use field-name
2418    // shorthand; Setup args use the local computed above.
2419    // Wire/PolyWire contribute nothing (no field).
2420    let new_field_inits: Vec<TokenStream2> = args
2421        .iter()
2422        .filter_map(|a| match &a.kind {
2423            ArgKind::Wire | ArgKind::PolyWire | ArgKind::Variadic(_) => None,
2424            ArgKind::Const(_) | ArgKind::ConstVec(_) | ArgKind::Setup(_) => {
2425                let n = &a.name;
2426                Some(quote!(#n))
2427            }
2428        })
2429        .collect();
2430
2431    // Per-arg bindings the eval body sees. Wire args unbox via
2432    // FromValue; const args wrap the struct field as `Const<T>`
2433    // so the user's body code sees the wrapper type matching
2434    // its function signature.
2435    let mut wire_idx = 0usize;
2436    let arg_bindings: Vec<TokenStream2> = args
2437        .iter()
2438        .map(|a| {
2439            let n = &a.name;
2440            match &a.kind {
2441                ArgKind::Wire => {
2442                    let idx = syn::Index::from(wire_idx);
2443                    wire_idx += 1;
2444                    let ty = &a.declared_ty;
2445                    // SRD-80b Phase B — dispatch:
2446                    //   1. `Arc<T>` Handle (non-special T) → inline
2447                    //      downcast (no blanket impl works).
2448                    //   2. Borrow shape (`&str`, `&[u8]`, `&[T]`,
2449                    //      `&serde_json::Value`) → direct
2450                    //      `match`-on-`Value`. Lifetime is naturally
2451                    //      `&inputs[i]`'s; no `unsafe` transmute.
2452                    //   3. Otherwise → `<#ty as Wire>::extract`.
2453                    if classify_wrapper_wire(ty) == Some(WrapperWire::Handle) {
2454                        let inner = extract_handle_inner(ty)
2455                            .expect("Handle classification implies Arc<T> shape");
2456                        quote! {
2457                            let #n: std::sync::Arc<#inner> = match &inputs[#idx] {
2458                                polydat::ast::Value::Handle(arc) => arc.clone()
2459                                    .downcast::<#inner>()
2460                                    .expect("Handle type mismatch — wiring bug"),
2461                                other => panic!("expected Handle, got {other:?}"),
2462                            };
2463                        }
2464                    } else if let Some(borrow) = is_borrow_wire_shape(ty) {
2465                        let extract = borrow_extract_tokens(borrow, quote!(&inputs[#idx]));
2466                        quote! {
2467                            let #n = #extract;
2468                        }
2469                    } else {
2470                        quote! {
2471                            let #n = <#ty as polydat::derive_support::Wire>::extract(&inputs[#idx]);
2472                        }
2473                    }
2474                }
2475                ArgKind::Const(shape) => {
2476                    let wrap = shape.wrap_as_const(quote!(self.#n));
2477                    quote! {
2478                        let #n = #wrap;
2479                    }
2480                }
2481                ArgKind::Setup(_) => {
2482                    // Setup arg: body sees a borrow of the
2483                    // construction-time computed field. No
2484                    // wrapping needed — the field is the
2485                    // user's named type and `&T` matches the
2486                    // function-signature borrow.
2487                    quote! {
2488                        let #n = &self.#n;
2489                    }
2490                }
2491                ArgKind::PolyWire => {
2492                    // SRD-80 PR B.8: PolyWire — clone the
2493                    // `Value` directly into a local. Body sees
2494                    // an owned `Value`.
2495                    let idx = syn::Index::from(wire_idx);
2496                    wire_idx += 1;
2497                    quote! {
2498                        let #n: polydat::ast::Value = inputs[#idx].clone();
2499                    }
2500                }
2501                ArgKind::Variadic(elem) => {
2502                    // SRD-80 PR B.9 + SRD-80b split-halves —
2503                    // materialise a Vec<T> from the inputs
2504                    // slice (per-element extraction), then bind
2505                    // the body local as `&[T]`. In single-
2506                    // variadic mode, the slice is `inputs` (all
2507                    // of them after the leading wires consumed
2508                    // their indices). In split-halves mode, the
2509                    // first variadic gets `inputs[0..n_wires]`
2510                    // and the second gets `inputs[n_wires..]`.
2511                    let extractor = elem.extract_from_value();
2512                    let owned = format_ident!("__{}_owned", a.name);
2513                    // Split-halves divides `inputs` at the
2514                    // midpoint at eval time. `inputs.len() / 2`
2515                    // is the per-half count; first variadic
2516                    // gets the low half, second gets the high.
2517                    let slice_expr = if is_split_halves {
2518                        let pos = variadic_positions[&a.name.to_string()];
2519                        if pos == 0 {
2520                            quote!({
2521                                let __half = inputs.len() / 2;
2522                                &inputs[..__half]
2523                            })
2524                        } else {
2525                            quote!({
2526                                let __half = inputs.len() / 2;
2527                                &inputs[__half..]
2528                            })
2529                        }
2530                    } else {
2531                        quote!(inputs)
2532                    };
2533                    quote! {
2534                        let #owned: Vec<_> = #slice_expr.iter().map(#extractor).collect();
2535                        let #n: &[_] = #owned.as_slice();
2536                    }
2537                }
2538                ArgKind::ConstVec(_) => {
2539                    // SRD-80b Phase C — `Const<Vec<C>>` body view:
2540                    // clone the cached Vec and wrap in `Const`.
2541                    // (Per-cycle clone matches the Wire-trait
2542                    // convention; JIT-ineligible by design.)
2543                    quote! {
2544                        let #n = polydat::derive_support::Const(self.#n.clone());
2545                    }
2546                }
2547            }
2548        })
2549        .collect();
2550
2551    // Build closure const-extraction logic. For each const arg
2552    // (in declaration order), pull from `consts: &[ConstArg]`
2553    // by index; fall back to the `poly_default` value if the
2554    // slice is shorter than the const arg list.
2555    //
2556    // For `ConstVec` args, collect every remaining entry from
2557    // `consts[i..]` into a `Vec<inner>` via the inner shape's
2558    // extractor — this consumes the tail of the consts slice
2559    // (only one ConstVec arg per function, enforced earlier).
2560    let mut const_idx_for_extract = 0usize;
2561    let const_extracts: Vec<TokenStream2> = args
2562        .iter()
2563        .filter_map(|a| match &a.kind {
2564            ArgKind::Wire | ArgKind::Setup(_) | ArgKind::PolyWire | ArgKind::Variadic(_) => None,
2565            ArgKind::Const(shape) => {
2566                let n = &a.name;
2567                let i = const_idx_for_extract;
2568                const_idx_for_extract += 1;
2569                let i_lit = syn::Index::from(i);
2570                let extract_present = shape.extract_from_const_arg(quote!(c));
2571                let fallback = match &a.default_value {
2572                    Some(default_expr) => {
2573                        // Default is an expression evaluating to
2574                        // the field type (`u64`, `f64`, `bool`,
2575                        // `String`). For Str: the expression
2576                        // should produce a `&str` or `String`; we
2577                        // call `.to_string()` to land on owned.
2578                        match shape {
2579                            ConstShape::Str => quote!((#default_expr).to_string()),
2580                            _ => quote!(#default_expr),
2581                        }
2582                    }
2583                    None => {
2584                        let msg = format!(
2585                            "missing required const arg '{n}' for function '{func_name_str}'"
2586                        );
2587                        quote!(return Some(Err(#msg.to_string())))
2588                    }
2589                };
2590                Some(quote! {
2591                    let #n: _ = match consts.get(#i_lit) {
2592                        Some(c) => #extract_present,
2593                        None => #fallback,
2594                    };
2595                })
2596            }
2597            ArgKind::ConstVec(inner) => {
2598                let n = &a.name;
2599                let i = const_idx_for_extract;
2600                // ConstVec consumes everything from index `i`
2601                // onward. const_idx_for_extract is intentionally
2602                // NOT bumped — by construction (validated below)
2603                // there's at most one ConstVec arg and it must be
2604                // the last arg, so no subsequent Const reads need
2605                // a higher base index.
2606                let i_lit = syn::LitInt::new(&i.to_string(), proc_macro2::Span::call_site());
2607                let extract_one = inner.extract_from_const_arg(quote!(c));
2608                Some(quote! {
2609                    let #n: Vec<_> = consts[#i_lit..].iter()
2610                        .map(|c| #extract_one)
2611                        .collect();
2612                })
2613            }
2614        })
2615        .collect();
2616
2617    // Names to pass to `Self::new(...)` from the build closure,
2618    // in declaration order. Const → `<name>`; PolyWire →
2619    // `<name>_type` (the local extracted from `wire_types`).
2620    let mut new_call_args: Vec<TokenStream2> = args
2621        .iter()
2622        .filter_map(|a| match &a.kind {
2623            ArgKind::Wire | ArgKind::Setup(_) | ArgKind::Variadic(_) => None,
2624            ArgKind::Const(_) | ArgKind::ConstVec(_) => {
2625                let n = &a.name;
2626                Some(quote!(#n))
2627            }
2628            ArgKind::PolyWire => {
2629                let n = format_ident!("{}_type", a.name);
2630                Some(quote!(#n))
2631            }
2632        })
2633        .collect();
2634    if has_variadic {
2635        new_call_args.push(quote!(n_wires));
2636    }
2637
2638    // SRD-80 PR B.9: when the function has a variadic arg,
2639    // extract `n_wires` from the `_wires: &[WireRef]` slice in
2640    // the build closure. The whole `_wires.len()` is the variadic
2641    // count (this PR supports one variadic arg only — when
2642    // multi-variadic lands, this extraction needs the per-arg
2643    // split logic).
2644    let variadic_n_wires_extract: TokenStream2 = if has_variadic {
2645        // Split-halves: assembler hands TOTAL wires; new() takes
2646        // the per-half count, so divide by 2 here too (matches
2647        // the variadic_ctor field's `n / 2`).
2648        if is_split_halves {
2649            quote! { let n_wires: usize = _wires.len() / 2; }
2650        } else {
2651            quote! { let n_wires: usize = _wires.len(); }
2652        }
2653    } else {
2654        quote!()
2655    };
2656
2657    // SRD-80 PR B.8: extract resolved PolyWire port types from
2658    // the `wire_types: &[PortType]` slice the assembler hands
2659    // the build closure. Wire/PolyWire share the same slot
2660    // counter (both consume a wire input position); we count
2661    // through args in declaration order.
2662    let polywire_extracts: Vec<TokenStream2> = {
2663        let mut wire_idx = 0usize;
2664        let mut out = Vec::new();
2665        for a in &args {
2666            match &a.kind {
2667                ArgKind::Wire => {
2668                    wire_idx += 1;
2669                }
2670                ArgKind::Variadic(_) => {
2671                    // Variadic args consume the REMAINDER of the
2672                    // wire slots. Only one variadic arg supported
2673                    // in this PR.
2674                    wire_idx += 0; // no positional increment
2675                }
2676                ArgKind::PolyWire => {
2677                    let pt_ident = format_ident!("{}_type", a.name);
2678                    let i = syn::Index::from(wire_idx);
2679                    let n_str = a.name.to_string();
2680                    let err = format!(
2681                        "polywire arg '{n_str}' for '{func_name_str}': assembler \
2682                         did not resolve a port type at wire index {wire_idx}"
2683                    );
2684                    out.push(quote! {
2685                        let #pt_ident: polydat::ast::PortType = match _wire_types.get(#i) {
2686                            Some(t) => *t,
2687                            None => return Some(Err(#err.to_string())),
2688                        };
2689                    });
2690                    wire_idx += 1;
2691                }
2692                ArgKind::Const(_) | ArgKind::ConstVec(_) | ArgKind::Setup(_) => {}
2693            }
2694        }
2695        out
2696    };
2697
2698    let block = &func.block;
2699
2700    // SRD-80b in-spirit `default_resolver` emission. Each wire
2701    // arg's `Wire::RESOLVER` const exposes the auto-resolver
2702    // intent at codegen time; the cascade picks the first
2703    // non-None among the wire-typed args. Non-Resolved wire
2704    // types contribute `None` (the trait default), so this
2705    // collapses cleanly to a no-resolver FuncSig for the
2706    // overwhelming majority of nodes.
2707    let default_resolver_field: TokenStream2 = {
2708        // Borrow shapes (`&str`, `&[u8]`, ...) don't impl `Wire`,
2709        // and `PolyWire` is excluded by ArgKind; only the
2710        // owned-type wire args contribute resolver intent.
2711        let wire_tys: Vec<&Type> = args
2712            .iter()
2713            .filter_map(|a| match &a.kind {
2714                ArgKind::Wire
2715                    if is_borrow_wire_shape(&a.declared_ty).is_none()
2716                        && classify_wrapper_wire(&a.declared_ty) != Some(WrapperWire::Handle) =>
2717                {
2718                    Some(&a.declared_ty)
2719                }
2720                _ => None,
2721            })
2722            .collect();
2723        if wire_tys.is_empty() {
2724            quote!(None)
2725        } else {
2726            // Build a right-to-left match cascade so the first
2727            // wire arg with a Some(_) resolver wins. Each step:
2728            //   match <ty as Wire>::RESOLVER { Some(r) => Some(r), None => <rest> }
2729            let mut acc = quote!(None);
2730            for ty in wire_tys.iter().rev() {
2731                acc = quote! {
2732                    match <#ty as polydat::derive_support::Wire>::RESOLVER {
2733                        Some(__r) => Some(__r),
2734                        None => #acc,
2735                    }
2736                };
2737            }
2738            acc
2739        }
2740    };
2741
2742    // Emit `Default` only when there are no const args AND no
2743    // setup args. Both require captured values to construct.
2744    let has_non_wire = args.iter().any(|a| !matches!(a.kind, ArgKind::Wire));
2745    let default_impl = if has_non_wire {
2746        quote!()
2747    } else {
2748        quote! {
2749            impl Default for #struct_name {
2750                fn default() -> Self { Self::new() }
2751            }
2752        }
2753    };
2754
2755    // SRD-80b Phase F (S18) — `#[polydat_node(decompose =
2756    // path)]` emits the FusedNode impl by delegating to the
2757    // named free function. Operators with bespoke fusion
2758    // logic (e.g. WeightedPick whose `decomposed()` body
2759    // builds a spec string) can still write their own
2760    // `impl FusedNode` block alongside the macro emission;
2761    // both compose because `decompose` is opt-in.
2762    let fused_node_impl: TokenStream2 = if let Some(path) = &attrs.decompose {
2763        quote! {
2764            impl polydat::compile::fusion::FusedNode for #struct_name {
2765                fn decomposed(&self) -> polydat::compile::fusion::DecomposedGraph {
2766                    #path(self)
2767                }
2768            }
2769        }
2770    } else {
2771        quote!()
2772    };
2773
2774    // ── SRD-80 PR B.7 — JIT eligibility + hook emission ──
2775    //
2776    // A node is Phase-2 eligible when every arg + return maps
2777    // to a `JitType` and no `#[poly_const]` setup arg is declared
2778    // (setup carries non-primitive derived state that can't fit a
2779    // u64 buffer). Override attributes (`compiled_u64 = ...`,
2780    // `jit_constants = ...`) bypass eligibility — they win
2781    // unconditionally.
2782
2783    let has_setup = args.iter().any(|a| matches!(a.kind, ArgKind::Setup(_)));
2784    let ret_jit_type = wire_type_to_jit_type(&ret_ty);
2785
2786    let arg_jit_types: Option<Vec<JitType>> = if has_setup {
2787        None
2788    } else {
2789        args.iter()
2790            .map(|a| match &a.kind {
2791                ArgKind::Wire => wire_type_to_jit_type(&a.declared_ty),
2792                // A const is captured by clone and never rides the
2793                // buffer, so its carrier is immaterial to eligibility.
2794                ArgKind::Const(shape) => {
2795                    Some(const_shape_to_jit_type(*shape).unwrap_or(JitType::U64))
2796                }
2797                // ConstVec is JIT-ineligible (the JIT u64 buffer
2798                // has no slot shape for a variable-length list).
2799                ArgKind::Setup(_) | ArgKind::PolyWire | ArgKind::ConstVec(_) => None,
2800                // SRD-80 PR B.9: variadic JIT — only `&[u64]`
2801                // rides the Phase 2 closure cleanly (the buffer
2802                // IS the slice). For f64/bool/Str variadics
2803                // the closure would need a per-call Vec
2804                // allocation to bit-reinterpret; skip in this PR.
2805                ArgKind::Variadic(elem) => match elem {
2806                    VariadicElement::U64 => Some(JitType::U64),
2807                    _ => None,
2808                },
2809            })
2810            .collect()
2811    };
2812
2813    // SRD-80 PR B.10/B.15: tuple return becomes JIT-eligible
2814    // when every element is JIT-eligible. The compiled_u64
2815    // closure destructures the result and writes each element
2816    // to its `outputs[i]` slot via the matching JitType.
2817    let tuple_ret_jit_types: Option<Vec<JitType>> = tuple_ret_elems.as_ref().and_then(|elems| {
2818        elems
2819            .iter()
2820            .map(wire_type_to_jit_type)
2821            .collect::<Option<Vec<_>>>()
2822    });
2823
2824    let jit_eligible = !is_fallible
2825        && arg_jit_types.is_some()
2826        && (ret_jit_type.is_some() || tuple_ret_jit_types.is_some());
2827
2828    // ── The slot kit (`compiled_slot`): the general compiled closure
2829    // over the flat slot buffer, for every node the u64 kit does not
2830    // carry (type_system_alignment.md §8.4 layer 3; jit_boundary.md,
2831    // axioms S1–S10). A scalar rides its slots as in the u64 kit.
2832    // Every `Ref2` port rides a `(ptr, len)` pair: a typed vector, a
2833    // string, or a byte string as a slice of its elements, and a JSON,
2834    // extension, or polymorphic value as a one-element slice holding
2835    // the `Value`. A `Ref2` output is written into the step's own
2836    // scratch entry, which the kernel owns and hands the closure
2837    // (axiom S3), and its pair is republished on every run; a `Ref2`
2838    // input is read through one dereference of the pair its producer
2839    // published (axiom S7). A polymorphic port and a variadic decode by
2840    // the wire types the kernel hands the kit, and a polymorphic return
2841    // encodes by the node's resolved output type. A const or const
2842    // list is captured by clone; a setup derived from consts is
2843    // recomputed from the captured consts, and a session-static setup
2844    // is captured from the node by clone; an `Option<T>` or `Config<T>`
2845    // over a carrier is the carrier's slot, wrapped.
2846    enum SlotArg {
2847        Jit(JitType),
2848        /// `Option<T>` over a one-slot carrier. A compiled kernel
2849        /// never carries `None` on a scalar slot (an unset extern is
2850        /// refused before the run), so the value is always present.
2851        Option(JitType),
2852        /// `Config<T>` over a carrier, the same slot wrapped, or over
2853        /// an owned string or byte string, copied out and wrapped.
2854        Config(ConfigInner),
2855        /// A typed vector slice, `&[T]`.
2856        Vec(&'static str),
2857        /// `&str`, or an owned `String` / `Arc<str>` copied out of
2858        /// the producer's bytes.
2859        Str {
2860            owned: bool,
2861        },
2862        /// `&[u8]`, or an owned `Vec<u8>` / `Arc<[u8]>` copied out.
2863        Bytes {
2864            owned: bool,
2865        },
2866        JsonRef,
2867        JsonArc,
2868        Ext,
2869        Poly,
2870        Variadic(VariadicElement),
2871        Const(ConstShape),
2872        ConstVec,
2873        Setup,
2874        /// A session-static setup (`from = ()`), captured from the
2875        /// node by clone: the closure sees what the node captured at
2876        /// construction, as the native form does through
2877        /// `jit_constants`.
2878        SetupStatic,
2879    }
2880    /// What a `Config<T>` wraps.
2881    #[derive(Clone, Copy)]
2882    enum ConfigInner {
2883        Jit(JitType),
2884        Str,
2885        Bytes,
2886    }
2887    /// One element of a return: a carrier, or a `Ref2` kind that
2888    /// takes a scratch entry of its own.
2889    #[derive(Clone, Copy)]
2890    enum SlotElem {
2891        Jit(JitType),
2892        Vec(&'static str),
2893        Str,
2894        Bytes,
2895        Json,
2896        Ext,
2897    }
2898    impl SlotElem {
2899        fn is_ref(self) -> bool {
2900            !matches!(self, SlotElem::Jit(_))
2901        }
2902        fn width(self) -> usize {
2903            match self {
2904                SlotElem::Jit(jt) => jt.width(),
2905                _ => 2,
2906            }
2907        }
2908        fn scratch_elem(self) -> Option<TokenStream2> {
2909            let name = match self {
2910                SlotElem::Jit(_) => return None,
2911                SlotElem::Vec(e) => e,
2912                SlotElem::Str => "Str",
2913                SlotElem::Bytes => "Bytes",
2914                SlotElem::Json | SlotElem::Ext => "Value",
2915            };
2916            let id = syn::Ident::new(name, proc_macro2::Span::call_site());
2917            Some(quote!(polydat::ast::ScratchElem::#id))
2918        }
2919    }
2920    enum SlotRet {
2921        Elem(SlotElem),
2922        /// A polymorphic `Value` return, encoded by the node's
2923        /// resolved output type.
2924        Poly,
2925        /// A tuple return: each element written by shape.
2926        Tuple(Vec<SlotElem>),
2927    }
2928    impl SlotRet {
2929        /// Whether any element takes a scratch entry.
2930        fn has_ref(&self) -> bool {
2931            match self {
2932                SlotRet::Elem(e) => e.is_ref(),
2933                SlotRet::Poly => true,
2934                SlotRet::Tuple(elems) => elems.iter().any(|e| e.is_ref()),
2935            }
2936        }
2937    }
2938    let owned_str_ty = |ty: &Type| -> bool {
2939        let flat: String = type_to_string(ty).split_whitespace().collect();
2940        matches!(flat.as_str(), "String" | "Arc<str>" | "std::sync::Arc<str>")
2941    };
2942    let owned_bytes_ty = |ty: &Type| -> bool {
2943        let flat: String = type_to_string(ty).split_whitespace().collect();
2944        matches!(
2945            flat.as_str(),
2946            "Vec<u8>" | "Arc<[u8]>" | "std::sync::Arc<[u8]>"
2947        )
2948    };
2949    let vec_ret_elem = |ty: &Type| -> Option<&'static str> {
2950        let flat: String = type_to_string(ty).split_whitespace().collect();
2951        match flat.as_str() {
2952            "Vec<f32>" => Some("F32"),
2953            "Vec<f64>" => Some("F64"),
2954            "Vec<half::f16>" | "Vec<f16>" => Some("F16"),
2955            "Vec<i8>" => Some("I8"),
2956            "Vec<i16>" => Some("I16"),
2957            "Vec<i32>" => Some("I32"),
2958            "Vec<i64>" => Some("I64"),
2959            _ => None,
2960        }
2961    };
2962    let classify_elem = |ty: &Type| -> Option<SlotElem> {
2963        if classify_wrapper_wire(ty) == Some(WrapperWire::Json) {
2964            Some(SlotElem::Json)
2965        } else if is_ext_wire(ty) {
2966            Some(SlotElem::Ext)
2967        } else if let Some(e) = vec_ret_elem(ty) {
2968            Some(SlotElem::Vec(e))
2969        } else if owned_str_ty(ty) {
2970            Some(SlotElem::Str)
2971        } else if owned_bytes_ty(ty) {
2972            Some(SlotElem::Bytes)
2973        } else {
2974            wire_type_to_jit_type(ty).map(SlotElem::Jit)
2975        }
2976    };
2977    // The return shape the kit can write: a carrier, a `Ref2` kind, a
2978    // polymorphic value, or a tuple of carriers and `Ref2` kinds.
2979    let classify_ret_shape = || -> Option<SlotRet> {
2980        if ret_is_polywire {
2981            return Some(SlotRet::Poly);
2982        }
2983        if let Some(elems) = &tuple_ret_elems {
2984            let shapes: Option<Vec<SlotElem>> = elems.iter().map(classify_elem).collect();
2985            return shapes.map(SlotRet::Tuple);
2986        }
2987        classify_elem(&ret_ty).map(SlotRet::Elem)
2988    };
2989    let slot_plan: Option<(Vec<SlotArg>, SlotRet)> = (|| {
2990        if is_fallible || dynamic_outputs_inner.is_some() {
2991            return None;
2992        }
2993        let ret_shape = classify_ret_shape()?;
2994        let mut shapes = Vec::with_capacity(args.len());
2995        for a in &args {
2996            let ty = &a.declared_ty;
2997            let shape = match &a.kind {
2998                ArgKind::Wire => match is_borrow_wire_shape(ty) {
2999                    Some(BorrowWire::Str) => SlotArg::Str { owned: false },
3000                    Some(BorrowWire::Bytes) => SlotArg::Bytes { owned: false },
3001                    Some(BorrowWire::Json) => SlotArg::JsonRef,
3002                    Some(BorrowWire::Vec(variant, _)) => match variant {
3003                        "VecF32" => SlotArg::Vec("F32"),
3004                        "VecF64" => SlotArg::Vec("F64"),
3005                        "VecF16" => SlotArg::Vec("F16"),
3006                        "VecI8" => SlotArg::Vec("I8"),
3007                        "VecI16" => SlotArg::Vec("I16"),
3008                        "VecI32" => SlotArg::Vec("I32"),
3009                        "VecI64" => SlotArg::Vec("I64"),
3010                        _ => return None,
3011                    },
3012                    None => {
3013                        if classify_wrapper_wire(ty) == Some(WrapperWire::Json) {
3014                            SlotArg::JsonArc
3015                        } else if is_ext_wire(ty) {
3016                            SlotArg::Ext
3017                        } else if owned_str_ty(ty) {
3018                            SlotArg::Str { owned: true }
3019                        } else if owned_bytes_ty(ty) {
3020                            SlotArg::Bytes { owned: true }
3021                        } else if let Some(inner) = option_inner(ty) {
3022                            let jt = wire_type_to_jit_type(inner)?;
3023                            if jt.width() != 1 {
3024                                return None;
3025                            }
3026                            SlotArg::Option(jt)
3027                        } else if let Some(inner) = config_inner(ty) {
3028                            SlotArg::Config(if owned_str_ty(inner) {
3029                                ConfigInner::Str
3030                            } else if owned_bytes_ty(inner) {
3031                                ConfigInner::Bytes
3032                            } else {
3033                                ConfigInner::Jit(wire_type_to_jit_type(inner)?)
3034                            })
3035                        } else {
3036                            SlotArg::Jit(wire_type_to_jit_type(ty)?)
3037                        }
3038                    }
3039                },
3040                ArgKind::PolyWire => SlotArg::Poly,
3041                ArgKind::Variadic(elem) => SlotArg::Variadic(*elem),
3042                ArgKind::Const(shape) => SlotArg::Const(*shape),
3043                ArgKind::ConstVec(_) => SlotArg::ConstVec,
3044                ArgKind::Setup(spec) => {
3045                    if spec.source_args.is_empty() {
3046                        SlotArg::SetupStatic
3047                    } else {
3048                        SlotArg::Setup
3049                    }
3050                }
3051            };
3052            shapes.push(shape);
3053        }
3054        // The u64 kit carries every node it is eligible for; this
3055        // kit takes the rest.
3056        if jit_eligible {
3057            return None;
3058        }
3059        Some((shapes, ret_shape))
3060    })();
3061    let slot_eligible = slot_plan.is_some();
3062
3063    // A fallible body ran once at construction; its cached value is
3064    // what every run writes. The shape decides which kit carries it.
3065    let fallible_ret: Option<SlotRet> = if is_fallible {
3066        classify_ret_shape()
3067    } else {
3068        None
3069    };
3070
3071    // Publish scratch entry `k`'s pair into the output slots at `o`.
3072    let publish = |k: usize, o: usize| -> TokenStream2 {
3073        let k = syn::Index::from(k);
3074        let o0 = syn::Index::from(o);
3075        let o1 = syn::Index::from(o + 1);
3076        quote! {
3077            let (__ptr, __len) = scratch[#k].ptr_len();
3078            outputs[#o0] = __ptr;
3079            outputs[#o1] = __len;
3080        }
3081    };
3082    // The write of one element `value` (typed `ty`) at output slot
3083    // `o`: a carrier as its bits, a `Ref2` kind into scratch entry
3084    // `k` with its pair republished (axiom S3).
3085    let write_elem =
3086        |e: SlotElem, ty: &Type, k: usize, o: usize, value: TokenStream2| -> TokenStream2 {
3087            let kk = syn::Index::from(k);
3088            let publish = publish(k, o);
3089            match e {
3090                SlotElem::Jit(jt) => jt.write_to_u64_buffer_at(o, value),
3091                SlotElem::Vec(elem) => {
3092                    let se = syn::Ident::new(elem, proc_macro2::Span::call_site());
3093                    quote! {
3094                        {
3095                            let polydat::ast::ScratchBuf::#se(__buf) = &mut scratch[#kk] else {
3096                                unreachable!("scratch element type mismatch");
3097                            };
3098                            *__buf = #value;
3099                        }
3100                        #publish
3101                    }
3102                }
3103                SlotElem::Str => quote! {
3104                    scratch[#kk].set_str(::core::convert::AsRef::<str>::as_ref(&#value));
3105                    #publish
3106                },
3107                SlotElem::Bytes => quote! {
3108                    scratch[#kk].set_bytes(::core::convert::AsRef::<[u8]>::as_ref(&#value));
3109                    #publish
3110                },
3111                SlotElem::Json => quote! {
3112                    scratch[#kk].set_value(polydat::ast::Value::Json(#value));
3113                    #publish
3114                },
3115                SlotElem::Ext => quote! {
3116                    scratch[#kk].set_value(<#ty as polydat::derive_support::Wire>::inject(#value));
3117                    #publish
3118                },
3119            }
3120        };
3121    // The write of `result` (typed `ret_ty`) by shape.
3122    let write_for = |shape: &SlotRet| -> TokenStream2 {
3123        match shape {
3124            SlotRet::Elem(e) => write_elem(*e, &ret_ty, 0, 0, quote!(result)),
3125            SlotRet::Poly => quote! {
3126                polydat::derive_support::write_poly(__out_type, result, scratch, outputs);
3127            },
3128            SlotRet::Tuple(elems) => {
3129                let types = tuple_ret_elems
3130                    .as_ref()
3131                    .expect("a tuple shape comes from a tuple return");
3132                let locals: Vec<Ident> = (0..elems.len())
3133                    .map(|i| format_ident!("__r_{}", i))
3134                    .collect();
3135                let mut k = 0usize;
3136                let mut o = 0usize;
3137                let writes: Vec<TokenStream2> = elems
3138                    .iter()
3139                    .enumerate()
3140                    .map(|(i, e)| {
3141                        let local = &locals[i];
3142                        let w = write_elem(*e, &types[i], k, o, quote!(#local));
3143                        if e.is_ref() {
3144                            k += 1;
3145                        }
3146                        o += e.width();
3147                        w
3148                    })
3149                    .collect();
3150                quote! {
3151                    let ( #( #locals ),* ) = result;
3152                    #( #writes )*
3153                }
3154            }
3155        }
3156    };
3157    // The scratch entries a return shape owns, in port order.
3158    let scratch_for = |shape: &SlotRet| -> TokenStream2 {
3159        match shape {
3160            SlotRet::Elem(e) => {
3161                let elems: Vec<TokenStream2> = e.scratch_elem().into_iter().collect();
3162                quote!(vec![ #( #elems ),* ])
3163            }
3164            SlotRet::Poly => quote!(
3165                polydat::ast::SlotShape::scratch_elem(&__out_type)
3166                    .into_iter()
3167                    .collect::<Vec<_>>()
3168            ),
3169            SlotRet::Tuple(elems) => {
3170                let elems: Vec<TokenStream2> =
3171                    elems.iter().filter_map(|e| e.scratch_elem()).collect();
3172                quote!(vec![ #( #elems ),* ])
3173            }
3174        }
3175    };
3176    // A polymorphic return encodes by the node's resolved output type,
3177    // which for the split-halves shape is the type of the first value
3178    // wire, the graph's own slot for the output being a placeholder
3179    // there. The graph colored the output slot by the declared port,
3180    // so a resolved type of another color has no slot to land in and
3181    // the node stays interpreted.
3182    let out_type_for = |shape: &SlotRet, fixed_ports: usize| -> TokenStream2 {
3183        if !matches!(shape, SlotRet::Poly) {
3184            return quote!();
3185        }
3186        let fixed = syn::Index::from(fixed_ports);
3187        let resolve = if is_split_halves {
3188            quote!(*wire_types.get(#fixed + (wire_types.len() - #fixed) / 2)?)
3189        } else {
3190            quote!(self.meta().outs[0].typ)
3191        };
3192        quote! {
3193            let __out_type: polydat::ast::PortType = #resolve;
3194            if polydat::ast::SlotShape::slot_color(&__out_type) != polydat::ast::SlotShape::slot_color(&self.meta().outs[0].typ) {
3195                return None;
3196            }
3197        }
3198    };
3199    let elem_ty_tokens = |elem: &str| -> TokenStream2 {
3200        match elem {
3201            "F32" => quote!(f32),
3202            "F64" => quote!(f64),
3203            "F16" => quote!(polydat::half::f16),
3204            "I8" => quote!(i8),
3205            "I16" => quote!(i16),
3206            "I32" => quote!(i32),
3207            "I64" => quote!(i64),
3208            _ => unreachable!(),
3209        }
3210    };
3211
3212    let compiled_slot_impl: TokenStream2 = if let Some(path) = &attrs.compiled_slot_override {
3213        quote! {
3214            fn compiled_slot(&self, wire_types: &[polydat::ast::PortType]) -> Option<polydat::ast::CompiledSlotKit> {
3215                Some(#path(self, wire_types))
3216            }
3217        }
3218    } else if let Some((shapes, ret_shape)) = &slot_plan {
3219        // Captures: consts and const lists by clone, then setups
3220        // recomputed from those captured consts exactly as `new()`
3221        // computes them (a setup is a pure function of its consts).
3222        let mut captures: Vec<TokenStream2> = Vec::new();
3223        for (a, shape) in args.iter().zip(shapes.iter()) {
3224            let n = &a.name;
3225            match shape {
3226                SlotArg::Const(_) | SlotArg::ConstVec | SlotArg::SetupStatic => {
3227                    captures.push(quote!(let #n = self.#n.clone();))
3228                }
3229                _ => {}
3230            }
3231        }
3232        for (a, shape) in args.iter().zip(shapes.iter()) {
3233            if let (SlotArg::Setup, ArgKind::Setup(spec)) = (shape, &a.kind) {
3234                let n = &a.name;
3235                let setup_fn = &spec.setup_fn;
3236                let src_exprs: Vec<TokenStream2> = spec
3237                    .source_args
3238                    .iter()
3239                    .map(|src| match const_shape_by_name.get(&src.to_string()) {
3240                        Some(ConstSourceShape::ScalarStr) => quote!(#src.as_str()),
3241                        Some(ConstSourceShape::ScalarValue) => quote!(#src),
3242                        Some(ConstSourceShape::VecValues) => quote!(&#src),
3243                        None => quote!(#src),
3244                    })
3245                    .collect();
3246                captures.push(quote!(let #n = #setup_fn( #( #src_exprs ),* );));
3247            }
3248        }
3249        // The reads walk the input slots with two run-time counters:
3250        // `__i`, the slot the next read starts at, and `__p`, its port,
3251        // which indexes the wire types the kernel handed the kit. A
3252        // polymorphic port and a variadic element are as wide as the
3253        // wire that feeds them, so their widths are read at run time.
3254        let fixed_ports: usize = shapes
3255            .iter()
3256            .filter(|s| {
3257                matches!(
3258                    s,
3259                    SlotArg::Jit(_)
3260                        | SlotArg::Option(_)
3261                        | SlotArg::Config(_)
3262                        | SlotArg::Vec(_)
3263                        | SlotArg::Str { .. }
3264                        | SlotArg::Bytes { .. }
3265                        | SlotArg::JsonRef
3266                        | SlotArg::JsonArc
3267                        | SlotArg::Ext
3268                        | SlotArg::Poly
3269                )
3270            })
3271            .count();
3272        let fixed = syn::Index::from(fixed_ports);
3273        // SAFETY (emitted): the pair was published by the producing
3274        // step into storage with a proven owner (its own scratch, an
3275        // extern's stored value, an interned constant, or a boundary
3276        // value alive for the call), and the layer-3 ownership rule
3277        // keeps it alive until that producer reruns.
3278        let pair_slice = |elem: TokenStream2| -> TokenStream2 {
3279            quote!(unsafe {
3280                ::core::slice::from_raw_parts(
3281                    inputs[__i] as usize as *const #elem,
3282                    inputs[__i + 1] as usize,
3283                )
3284            })
3285        };
3286        let str_read = {
3287            let s = pair_slice(quote!(u8));
3288            quote!(unsafe { ::core::str::from_utf8_unchecked(#s) })
3289        };
3290        let bytes_read = pair_slice(quote!(u8));
3291        let arg_reads: Vec<TokenStream2> = args
3292            .iter()
3293            .zip(shapes.iter())
3294            .map(|(a, shape)| {
3295                let n = &a.name;
3296                let ty = &a.declared_ty;
3297                match shape {
3298                    SlotArg::Jit(jt) => {
3299                        let read = jt.read_from_u64_buffer(0);
3300                        let w = jt.width();
3301                        quote! {
3302                            let #n = { let inputs = &inputs[__i..]; #read };
3303                            __i += #w;
3304                            __p += 1;
3305                        }
3306                    }
3307                    SlotArg::Option(jt) => {
3308                        let read = jt.read_from_u64_buffer(0);
3309                        quote! {
3310                            let #n: #ty = Some({ let inputs = &inputs[__i..]; #read });
3311                            __i += 1;
3312                            __p += 1;
3313                        }
3314                    }
3315                    SlotArg::Config(ConfigInner::Jit(jt)) => {
3316                        let read = jt.read_from_u64_buffer(0);
3317                        let w = jt.width();
3318                        quote! {
3319                            let #n: #ty = polydat::derive_support::Config({ let inputs = &inputs[__i..]; #read });
3320                            __i += #w;
3321                            __p += 1;
3322                        }
3323                    }
3324                    SlotArg::Config(ConfigInner::Str) => quote! {
3325                        let __s: &str = #str_read;
3326                        let #n: #ty = polydat::derive_support::Config(::core::convert::From::from(__s));
3327                        __i += 2;
3328                        __p += 1;
3329                    },
3330                    SlotArg::Config(ConfigInner::Bytes) => quote! {
3331                        let __b: &[u8] = #bytes_read;
3332                        let #n: #ty = polydat::derive_support::Config(::core::convert::From::from(__b));
3333                        __i += 2;
3334                        __p += 1;
3335                    },
3336                    SlotArg::Vec(elem) => {
3337                        let et = elem_ty_tokens(elem);
3338                        let s = pair_slice(et.clone());
3339                        quote! {
3340                            let #n: &[#et] = #s;
3341                            __i += 2;
3342                            __p += 1;
3343                        }
3344                    }
3345                    SlotArg::Str { owned } => {
3346                        let bind = if *owned {
3347                            quote!(let #n: #ty = ::core::convert::From::from(__s);)
3348                        } else {
3349                            quote!(let #n: &str = __s;)
3350                        };
3351                        quote! {
3352                            let __s: &str = #str_read;
3353                            #bind
3354                            __i += 2;
3355                            __p += 1;
3356                        }
3357                    }
3358                    SlotArg::Bytes { owned } => {
3359                        let bind = if *owned {
3360                            quote!(let #n: #ty = ::core::convert::From::from(__b);)
3361                        } else {
3362                            quote!(let #n: &[u8] = __b;)
3363                        };
3364                        quote! {
3365                            let __b: &[u8] = #bytes_read;
3366                            #bind
3367                            __i += 2;
3368                            __p += 1;
3369                        }
3370                    }
3371                    SlotArg::JsonRef => quote! {
3372                        let #n = match polydat::derive_support::ref_value(&inputs[__i..]) {
3373                            polydat::ast::Value::Json(__j) => &**__j,
3374                            __other => panic!("expected Json wire, got {__other:?}"),
3375                        };
3376                        __i += 2;
3377                        __p += 1;
3378                    },
3379                    SlotArg::JsonArc => quote! {
3380                        let #n = match polydat::derive_support::ref_value(&inputs[__i..]) {
3381                            polydat::ast::Value::Json(__j) => __j.clone(),
3382                            __other => panic!("expected Json wire, got {__other:?}"),
3383                        };
3384                        __i += 2;
3385                        __p += 1;
3386                    },
3387                    SlotArg::Ext => quote! {
3388                        let #n: #ty = <#ty as polydat::derive_support::Wire>::extract(
3389                            polydat::derive_support::ref_value(&inputs[__i..]),
3390                        );
3391                        __i += 2;
3392                        __p += 1;
3393                    },
3394                    SlotArg::Poly => quote! {
3395                        let #n: polydat::ast::Value =
3396                            polydat::derive_support::read_poly(__wire_types[__p], &inputs[__i..]);
3397                        __i += polydat::ast::SlotShape::slot_width(&__wire_types[__p]);
3398                        __p += 1;
3399                    },
3400                    SlotArg::Variadic(elem) => {
3401                        // A variadic takes every remaining port, or in
3402                        // the split-halves shape (`pick`), its half of
3403                        // them: the selectors first, then the values.
3404                        let count = if is_split_halves {
3405                            let pos = variadic_positions[&a.name.to_string()];
3406                            if pos == 0 {
3407                                quote!((__wire_types.len() - #fixed) / 2)
3408                            } else {
3409                                quote!(__wire_types.len() - #fixed - (__wire_types.len() - #fixed) / 2)
3410                            }
3411                        } else {
3412                            quote!(__wire_types.len() - #fixed)
3413                        };
3414                        let owned = format_ident!("__{}_owned", a.name);
3415                        let (elem_ty, extract, width) = match elem {
3416                            VariadicElement::U64 => (quote!(u64), quote!(inputs[__i]), quote!(1)),
3417                            VariadicElement::Bool => (quote!(bool), quote!(inputs[__i] != 0), quote!(1)),
3418                            VariadicElement::BorrowedStr => (quote!(&str), str_read.clone(), quote!(2)),
3419                            VariadicElement::OwnedString => {
3420                                (quote!(String), quote!((#str_read).to_string()), quote!(2))
3421                            }
3422                            VariadicElement::Value => (
3423                                quote!(polydat::ast::Value),
3424                                quote!(polydat::derive_support::read_poly(__wire_types[__p], &inputs[__i..])),
3425                                quote!(polydat::ast::SlotShape::slot_width(&__wire_types[__p])),
3426                            ),
3427                        };
3428                        quote! {
3429                            let mut #owned: Vec<#elem_ty> = Vec::with_capacity(#count);
3430                            for _ in 0..#count {
3431                                let __v: #elem_ty = #extract;
3432                                __i += #width;
3433                                __p += 1;
3434                                #owned.push(__v);
3435                            }
3436                            let #n = &#owned[..];
3437                        }
3438                    }
3439                    SlotArg::Const(shape) => {
3440                        let wrap = shape.wrap_as_const(quote!(#n));
3441                        quote!(let #n = #wrap;)
3442                    }
3443                    SlotArg::ConstVec => quote!(let #n = polydat::derive_support::Const(#n.clone());),
3444                    SlotArg::Setup | SlotArg::SetupStatic => quote!(let #n = &#n;),
3445                }
3446            })
3447            .collect();
3448        let arg_names: Vec<&syn::Ident> = args.iter().map(|a| &a.name).collect();
3449        let write = write_for(ret_shape);
3450        let scratch = scratch_for(ret_shape);
3451        let out_type = out_type_for(ret_shape, fixed_ports);
3452        quote! {
3453            #[allow(unused_mut, unused_variables, unused_assignments, clippy::unused_unit)]
3454            fn compiled_slot(&self, wire_types: &[polydat::ast::PortType]) -> Option<polydat::ast::CompiledSlotKit> {
3455                #( #captures )*
3456                #out_type
3457                let __wire_types: Vec<polydat::ast::PortType> = wire_types.to_vec();
3458                let __scratch: Vec<polydat::ast::ScratchElem> = #scratch;
3459                Some(polydat::ast::CompiledSlotKit {
3460                    scratch: __scratch,
3461                    op: Box::new(move |inputs: &[u64], outputs: &mut [u64], scratch: &mut [polydat::ast::ScratchBuf]| {
3462                        let mut __i: usize = 0;
3463                        let mut __p: usize = 0;
3464                        #( #arg_reads )*
3465                        let result: #ret_ty = Self::__polydat_body( #( #arg_names ),* );
3466                        #write
3467                    }),
3468                })
3469            }
3470        }
3471    } else if let Some(shape) = fallible_ret.as_ref().filter(|s| s.has_ref()) {
3472        // A fallible node whose cached value is a `Ref2` kind: the
3473        // closure writes the same value into its scratch every run it
3474        // is asked for, which is once, since nothing reaches it.
3475        let write = write_for(shape);
3476        let scratch = scratch_for(shape);
3477        let out_type = out_type_for(shape, 0);
3478        quote! {
3479            #[allow(unused_variables)]
3480            fn compiled_slot(&self, wire_types: &[polydat::ast::PortType]) -> Option<polydat::ast::CompiledSlotKit> {
3481                #out_type
3482                let __cached = self.__polydat_cached.clone();
3483                Some(polydat::ast::CompiledSlotKit {
3484                    scratch: #scratch,
3485                    op: Box::new(move |_inputs: &[u64], outputs: &mut [u64], scratch: &mut [polydat::ast::ScratchBuf]| {
3486                        let result: #ret_ty = __cached.clone();
3487                        #write
3488                    }),
3489                })
3490            }
3491        }
3492    } else {
3493        quote!()
3494    };
3495
3496    let emit_jit_constants = attrs.jit_constants_override.is_some() || jit_eligible;
3497
3498    // Body sharing: extract the function body into a private
3499    // associated fn `__polydat_body` when JIT is emitted. Both
3500    // `eval()` (Value boxing path) and `compiled_u64()` (u64
3501    // buffer path) call it. Single source of truth.
3502    //
3503    // When JIT is not emitted, the body stays inlined inside
3504    // `eval()`'s current `#[allow(unused_variables)]` block
3505    // (Setup-bearing nodes need this — their body references
3506    // setup-derived locals via `let n = &self.n` bindings).
3507
3508    let use_shared_body = jit_eligible || slot_eligible;
3509
3510    // Body-fn parameter list — every arg in its DECLARED form
3511    // (wire as bare type, const as `Const<T>`, setup as `&T`).
3512    let body_params: Vec<TokenStream2> = args
3513        .iter()
3514        .map(|a| {
3515            let n = &a.name;
3516            let t = &a.declared_ty;
3517            // A `Const<T>` is spelled by its bare name in the source
3518            // signature; the shared body must not depend on the
3519            // module having imported it.
3520            if let (ArgKind::Const(_) | ArgKind::ConstVec(_), syn::Type::Path(p)) = (&a.kind, t)
3521                && let Some(last) = p.path.segments.last()
3522                && last.ident == "Const"
3523            {
3524                let generics = &last.arguments;
3525                return quote!(#n: polydat::derive_support::Const #generics);
3526            }
3527            quote!(#n: #t)
3528        })
3529        .collect();
3530
3531    let body_fn_def: TokenStream2 = if is_fallible {
3532        // SRD-80b Phase 5 S16 — fallible body. Body returns the
3533        // declared Result<T, E>; try_new runs it once at
3534        // construction and propagates Err as String via Into.
3535        quote! {
3536            #[inline(always)]
3537            #[allow(unused_variables)]
3538            #[allow(clippy::ptr_arg)]
3539            fn __polydat_body( #( #body_params ),* ) -> #declared_ret_ty #block
3540        }
3541    } else if use_shared_body {
3542        quote! {
3543            #[inline(always)]
3544            #[allow(unused_variables)]
3545            #[allow(clippy::ptr_arg)]
3546            fn __polydat_body( #( #body_params ),* ) -> #ret_ty #block
3547        }
3548    } else {
3549        quote!()
3550    };
3551
3552    // Helper: emit `outputs[idx] = <conversion>(value)` for a
3553    // given element type. SRD-80b Phase B — owned types route
3554    // through `<T as Wire>::inject`; Handle keeps its inline
3555    // upcast (no blanket impl works). Returning a borrow shape
3556    // (`&str`, `&[u8]`, etc.) from a node body is unusual but
3557    // supported: the borrow's `into()` already exists for the
3558    // canonical `Value` constructor; we emit that directly.
3559    let output_assign = |idx_lit: TokenStream2,
3560                         elem_ty: &Type,
3561                         local: TokenStream2|
3562     -> TokenStream2 {
3563        if classify_wrapper_wire(elem_ty) == Some(WrapperWire::Handle) {
3564            quote! {
3565                outputs[#idx_lit] = polydat::ast::Value::handle(#local);
3566            }
3567        } else if classify_polywire(elem_ty) {
3568            // PolyWire return: body returns `Value` directly, move
3569            // it into the outputs slot. No trait dispatch — Value
3570            // has no static port type (it's polymorphic at runtime).
3571            quote! {
3572                outputs[#idx_lit] = #local;
3573            }
3574        } else if let Some(borrow) = is_borrow_wire_shape(elem_ty) {
3575            // Borrow-typed returns: construct the matching Value
3576            // variant from the borrow via the existing
3577            // `Into<Value>` / Arc::from path. `&str` →
3578            // `Value::Str(arc)`; `&[u8]` → `Value::Bytes(arc)`;
3579            // typed-vec borrows → `Value::Vec*(SliceArc::from(slice))`.
3580            match borrow {
3581                BorrowWire::Str => quote! {
3582                    outputs[#idx_lit] = polydat::ast::Value::Str((#local).into());
3583                },
3584                BorrowWire::Bytes => quote! {
3585                    outputs[#idx_lit] = polydat::ast::Value::Bytes((#local).into());
3586                },
3587                BorrowWire::Json => quote! {
3588                    outputs[#idx_lit] = polydat::ast::Value::Json(::std::sync::Arc::new((#local).clone()));
3589                },
3590                BorrowWire::Vec(variant, _) => {
3591                    let v = syn::Ident::new(variant, proc_macro2::Span::call_site());
3592                    quote! {
3593                        outputs[#idx_lit] = polydat::ast::Value::#v(polydat::ast::SliceArc::from_vec((#local).to_vec()));
3594                    }
3595                }
3596            }
3597        } else {
3598            quote! {
3599                outputs[#idx_lit] = <#elem_ty as polydat::derive_support::Wire>::inject(#local);
3600            }
3601        }
3602    };
3603
3604    // SRD-80b `DynamicOutputs<T>` — build the `outs:` vec at
3605    // construction from the driving `Const<Vec<C>>` arg's
3606    // length. Used by both the infallible `new()` and the
3607    // fallible `try_new()` paths below.
3608    let outs_build: TokenStream2 = if let (Some(inner), Some(count_arg)) =
3609        (&dynamic_outputs_inner, &dynamic_outputs_count_arg)
3610    {
3611        quote! {
3612            let outs: Vec<polydat::ast::Port> = (0..#count_arg.len())
3613                .map(|__i| polydat::ast::Port::new(
3614                    format!("d{}", __i),
3615                    <#inner as polydat::derive_support::Wire>::PORT,
3616                ))
3617                .collect();
3618        }
3619    } else {
3620        quote! {
3621            let outs = vec![ #(
3622                polydat::ast::Port::new(#output_names_strs, #output_port_types)
3623            ),* ];
3624        }
3625    };
3626
3627    // SRD-80 PR B.10/B.11: result → outputs translation. For
3628    // single-output, write `outputs[0] = ...(result)`. For
3629    // tuple-output, destructure and per-element write. For
3630    // SRD-80b `DynamicOutputs<T>`, iterate the returned Vec
3631    // and inject each element via the inner type's Wire impl.
3632    let result_to_outputs: TokenStream2 = if let Some(inner) = &dynamic_outputs_inner {
3633        let inject_one = if classify_polywire(inner) {
3634            quote!(__elem)
3635        } else if let Some(borrow) = is_borrow_wire_shape(inner) {
3636            match borrow {
3637                BorrowWire::Str => quote!(polydat::ast::Value::Str((__elem).into())),
3638                BorrowWire::Bytes => quote!(polydat::ast::Value::Bytes((__elem).into())),
3639                BorrowWire::Json => quote!(polydat::ast::Value::Json(::std::sync::Arc::new(
3640                    (__elem).clone()
3641                ))),
3642                BorrowWire::Vec(variant, _) => {
3643                    let v = syn::Ident::new(variant, proc_macro2::Span::call_site());
3644                    quote!(polydat::ast::Value::#v(polydat::ast::SliceArc::from_vec((__elem).to_vec())))
3645                }
3646            }
3647        } else {
3648            quote!(<#inner as polydat::derive_support::Wire>::inject(__elem))
3649        };
3650        quote! {
3651            for (__i, __elem) in result.0.into_iter().enumerate() {
3652                outputs[__i] = #inject_one;
3653            }
3654        }
3655    } else if let Some(elems) = &tuple_ret_elems {
3656        let locals: Vec<Ident> = (0..elems.len())
3657            .map(|i| format_ident!("__r_{}", i))
3658            .collect();
3659        let writes: Vec<TokenStream2> = elems
3660            .iter()
3661            .enumerate()
3662            .map(|(i, elem_ty)| {
3663                let local = &locals[i];
3664                let idx = syn::Index::from(i);
3665                output_assign(quote!(#idx), elem_ty, quote!(#local))
3666            })
3667            .collect();
3668        quote! {
3669            let ( #( #locals ),* ) = result;
3670            #( #writes )*
3671        }
3672    } else {
3673        output_assign(quote!(0), &ret_ty, quote!(result))
3674    };
3675
3676    // Eval-path arg bindings + body-call. When JIT is emitted,
3677    // eval() unboxes from Values and calls `__polydat_body`.
3678    // When JIT is not emitted, the body stays inline in
3679    // `eval()` for back-compat with Setup-bearing nodes.
3680    let eval_body: TokenStream2 = if use_shared_body {
3681        let arg_names: Vec<&syn::Ident> = args.iter().map(|a| &a.name).collect();
3682        quote! {
3683            #[allow(unused_variables)]
3684            {
3685                #( #arg_bindings )*
3686                let result: #ret_ty = Self::__polydat_body( #( #arg_names ),* );
3687                #result_to_outputs
3688            }
3689        }
3690    } else {
3691        quote! {
3692            #[allow(unused_variables)]
3693            {
3694                #( #arg_bindings )*
3695                let result: #ret_ty = (|| #block)();
3696                #result_to_outputs
3697            }
3698        }
3699    };
3700
3701    // compiled_u64() emission. Three cases:
3702    //   (a) Override path supplied → call it.
3703    //   (b) JIT eligible and not opted out → emit closure that
3704    //       reads from u64 buffer, captures const fields by
3705    //       Copy, calls __polydat_body, writes back.
3706    //   (c) Otherwise → don't override the trait default
3707    //       (returns None).
3708    let state_impl: TokenStream2 = if let Some(path) = &attrs.state {
3709        quote! {
3710            fn scratch_layout(&self) -> Vec<polydat::ast::ScratchElem> {
3711                #path::layout(self)
3712            }
3713            fn eval_in(
3714                &self,
3715                scratch: &mut [polydat::ast::ScratchBuf],
3716                inputs: &[polydat::ast::Value],
3717                outputs: &mut [polydat::ast::Value],
3718            ) {
3719                #path::eval(self, scratch, inputs, outputs)
3720            }
3721        }
3722    } else {
3723        quote!()
3724    };
3725
3726    let compiled_u64_impl: TokenStream2 = if let Some(path) = &attrs.compiled_u64_override {
3727        // SRD-80b in-spirit refinement — pass `&self` to the
3728        // override fn so setup-derived state (round_keys,
3729        // half_bits, etc.) is reachable. The override fn
3730        // signature is now `fn(&Self) -> CompiledU64Op`.
3731        quote! {
3732            fn compiled_u64(&self) -> Option<polydat::ast::CompiledU64Op> {
3733                Some(#path(self))
3734            }
3735        }
3736    } else if let Some(shape) = fallible_ret.as_ref().filter(|s| !s.has_ref()) {
3737        // A fallible node whose cached value is a carrier (or a tuple
3738        // of carriers): the closure writes it every run.
3739        let write = write_for(shape);
3740        quote! {
3741            fn compiled_u64(&self) -> Option<polydat::ast::CompiledU64Op> {
3742                let __cached = self.__polydat_cached.clone();
3743                Some(Box::new(move |_inputs: &[u64], outputs: &mut [u64]| {
3744                    let result: #ret_ty = __cached.clone();
3745                    #write
3746                }))
3747            }
3748        }
3749    } else if jit_eligible {
3750        // Per-arg jit handling. Wire args read from inputs at
3751        // the next sequential index. Const args capture by Copy
3752        // from self at closure-creation time, then re-wrap as
3753        // `Const<T>` inside the closure for handoff to body.
3754        let jit_types = arg_jit_types.as_ref().unwrap();
3755        let mut wire_buf_idx = 0usize;
3756
3757        let captures: Vec<TokenStream2> = args
3758            .iter()
3759            .filter_map(|a| match &a.kind {
3760                ArgKind::Wire | ArgKind::Variadic(_) => None,
3761                ArgKind::Const(_) => {
3762                    let n = &a.name;
3763                    Some(quote!(let #n = self.#n.clone();))
3764                }
3765                ArgKind::Setup(_) | ArgKind::PolyWire | ArgKind::ConstVec(_) => {
3766                    unreachable!("setup/polywire/constvec excludes JIT eligibility")
3767                }
3768            })
3769            .collect();
3770
3771        let arg_reads: Vec<TokenStream2> = args
3772            .iter()
3773            .zip(jit_types.iter())
3774            .map(|(a, jt)| {
3775                let n = &a.name;
3776                let _ = jt;
3777                match &a.kind {
3778                    ArgKind::Wire => {
3779                        let read = jt.read_from_u64_buffer(wire_buf_idx);
3780                        wire_buf_idx += jt.width();
3781                        quote!(let #n = #read;)
3782                    }
3783                    ArgKind::Const(shape) => {
3784                        if *shape == ConstShape::Str {
3785                            quote!(let #n = polydat::derive_support::Const(#n.as_str());)
3786                        } else {
3787                            quote!(let #n = polydat::derive_support::Const(#n);)
3788                        }
3789                    }
3790                    ArgKind::Variadic(_) => {
3791                        // SRD-80 PR B.9: u64 variadic — pass the
3792                        // whole `inputs: &[u64]` buffer directly
3793                        // to the body. Zero allocation, zero conversion.
3794                        // (Non-u64 variadics aren't JIT-eligible —
3795                        // this branch is only reached for u64 elems.)
3796                        quote!(let #n: &[u64] = inputs;)
3797                    }
3798                    ArgKind::Setup(_) | ArgKind::PolyWire | ArgKind::ConstVec(_) => unreachable!(),
3799                }
3800            })
3801            .collect();
3802
3803        let arg_names: Vec<&syn::Ident> = args.iter().map(|a| &a.name).collect();
3804        // SRD-80 PR B.15: multi-output write. For single-output
3805        // ret, `write` emits `outputs[0] = bits(result)`. For
3806        // tuple-output, destructure into locals and emit a
3807        // per-element write line.
3808        let write = if let Some(tuple_jits) = &tuple_ret_jit_types {
3809            let locals: Vec<Ident> = (0..tuple_jits.len())
3810                .map(|i| format_ident!("__jit_r_{}", i))
3811                .collect();
3812            // Per-element write at the element's slot OFFSET (the
3813            // prefix sum of preceding element widths — §8.4 L1).
3814            let mut out_off = 0usize;
3815            let writes: Vec<TokenStream2> = tuple_jits
3816                .iter()
3817                .enumerate()
3818                .map(|(i, jt)| {
3819                    let local = &locals[i];
3820                    let w = jt.write_to_u64_buffer_at(out_off, quote!(#local));
3821                    out_off += jt.width();
3822                    w
3823                })
3824                .collect();
3825            quote! {
3826                let ( #( #locals ),* ) = result;
3827                #( #writes )*
3828            }
3829        } else {
3830            let ret_jit = ret_jit_type.unwrap();
3831            ret_jit.write_to_u64_buffer(quote!(result))
3832        };
3833
3834        quote! {
3835            fn compiled_u64(&self) -> Option<polydat::ast::CompiledU64Op> {
3836                #( #captures )*
3837                Some(Box::new(move |inputs: &[u64], outputs: &mut [u64]| {
3838                    #( #arg_reads )*
3839                    let result: #ret_ty = Self::__polydat_body( #( #arg_names ),* );
3840                    #write
3841                }))
3842            }
3843        }
3844    } else {
3845        quote!()
3846    };
3847
3848    // jit_constants() emission. Three cases:
3849    //   (a) Override path supplied → call it with `&self`.
3850    //   (b) JIT eligible and not opted out → emit a Vec<u64>
3851    //       built from const fields in declaration order,
3852    //       bit-reinterpreting f64 and 0/1-encoding bool.
3853    //   (c) Otherwise → don't override the trait default.
3854    let jit_constants_impl: TokenStream2 = if let Some(path) = &attrs.jit_constants_override {
3855        quote! {
3856            fn jit_constants(&self) -> Vec<u64> {
3857                #path(self)
3858            }
3859        }
3860    } else if emit_jit_constants {
3861        let const_encodings: Vec<TokenStream2> = args
3862            .iter()
3863            .filter_map(|a| match &a.kind {
3864                ArgKind::Const(shape) => {
3865                    let jt = const_shape_to_jit_type(*shape)?;
3866                    let n = &a.name;
3867                    Some(jt.const_field_as_u64(quote!(self.#n)))
3868                }
3869                _ => None,
3870            })
3871            .collect();
3872
3873        quote! {
3874            fn jit_constants(&self) -> Vec<u64> {
3875                vec![ #( #const_encodings ),* ]
3876            }
3877        }
3878    } else {
3879        quote!()
3880    };
3881
3882    // purity() emission — only when attribute is set; otherwise
3883    // the trait default (`Pure`) is used.
3884    //
3885    // Two attribute shapes:
3886    //   - `Expr::Path` (e.g. `Nondeterministic`)
3887    //     → `Purity::Nondeterministic`
3888    //   - `Expr::Call` (e.g. `SideChannel(LogBuffer)`)
3889    //     → `Purity::SideChannel { sink: SideChannelSink::LogBuffer }`
3890    let purity_impl: TokenStream2 = match &attrs.purity {
3891        None => quote!(),
3892        Some(syn::Expr::Path(p)) => {
3893            let variant = &p.path;
3894            quote! {
3895                fn purity(&self) -> polydat::ast::Purity {
3896                    polydat::ast::Purity::#variant
3897                }
3898            }
3899        }
3900        Some(syn::Expr::Call(c)) => {
3901            // SRD-80 PR B.7/B.11: dispatch on the variant head.
3902            //   SideChannel(<SideChannelSink variant>) →
3903            //     Purity::SideChannel { sink: SideChannelSink::<arg> }
3904            //   Nondeterministic(<&'static str reason>) →
3905            //     Purity::Nondeterministic { reason: <arg> }
3906            let syn::Expr::Path(head_path) = &*c.func else {
3907                return Err(syn::Error::new_spanned(
3908                    &c.func,
3909                    "purity call-form expects a Purity variant ident as the head.",
3910                ));
3911            };
3912            let head_ident = head_path.path.get_ident().ok_or_else(|| {
3913                syn::Error::new_spanned(
3914                    &c.func,
3915                    "purity call-form head must be a single Purity variant ident.",
3916                )
3917            })?;
3918            let arg = c.args.first().ok_or_else(|| {
3919                syn::Error::new_spanned(c, "purity call-form requires one argument.")
3920            })?;
3921            match head_ident.to_string().as_str() {
3922                "SideChannel" => quote! {
3923                    fn purity(&self) -> polydat::ast::Purity {
3924                        polydat::ast::Purity::SideChannel {
3925                            sink: polydat::ast::SideChannelSink::#arg,
3926                        }
3927                    }
3928                },
3929                "Nondeterministic" => quote! {
3930                    fn purity(&self) -> polydat::ast::Purity {
3931                        polydat::ast::Purity::Nondeterministic { reason: #arg }
3932                    }
3933                },
3934                other => {
3935                    return Err(syn::Error::new_spanned(
3936                        head_ident,
3937                        format!(
3938                            "purity call-form head `{other}` not recognized. \
3939                         Use `SideChannel(<sink>)` or `Nondeterministic(<reason>)`."
3940                        ),
3941                    ));
3942                }
3943            }
3944        }
3945        Some(other) => {
3946            return Err(syn::Error::new_spanned(
3947                other,
3948                "purity attribute must be a Purity variant path or call form",
3949            ));
3950        }
3951    };
3952
3953    let simd_variant_impl: TokenStream2 = match &attrs.simd {
3954        None => quote!(),
3955        Some(vector_node) if attrs.simd_total => quote! {
3956            fn simd_variant(&self) -> Option<polydat::ast::SimdVariant> {
3957                Some(polydat::ast::SimdVariant::exact_total(#vector_node))
3958            }
3959        },
3960        Some(vector_node) => quote! {
3961            fn simd_variant(&self) -> Option<polydat::ast::SimdVariant> {
3962                Some(polydat::ast::SimdVariant::exact_fallible(#vector_node))
3963            }
3964        },
3965    };
3966
3967    // SRD-80 PR B.9: conditional FuncSig fields.
3968    let identity_field: TokenStream2 = if let Some(expr) = &attrs.identity {
3969        quote!(Some(#expr))
3970    } else {
3971        quote!(None)
3972    };
3973
3974    // `variadic_ctor` only emitted for pure-variadic nodes (no
3975    // const args, no PolyWire). Const+variadic mixing would need
3976    // the ctor to thread the const values through — defer to a
3977    // future PR.
3978    let has_const_arg = args.iter().any(|a| matches!(a.kind, ArgKind::Const(_)));
3979    let has_polywire = args.iter().any(|a| matches!(a.kind, ArgKind::PolyWire));
3980    let variadic_ctor_field: TokenStream2 = if has_variadic && !has_const_arg && !has_polywire {
3981        // Split-halves: assembler passes TOTAL wire count; the
3982        // struct's `new()` takes per-half count, so divide by 2.
3983        if is_split_halves {
3984            quote!(Some(|n| Box::new(#struct_name::new(n / 2))))
3985        } else {
3986            quote!(Some(|n| Box::new(#struct_name::new(n))))
3987        }
3988    } else {
3989        quote!(None)
3990    };
3991
3992    // SRD-80b Phase C — `Option<T>` arg auto-emits
3993    // `accepts_none_inputs() -> true`. The runtime kernel's
3994    // SRD-74 Rule 1 propagation short-circuits `Value::None`
3995    // inputs by default; `Option<T>` is the canonical opt-in
3996    // shape that wants None routed to the body instead.
3997    // SRD-80b in-spirit rule — `Option<T>` wire args declare
3998    // None-tolerance via the type system; PolyWire (`Value`) args
3999    // ARE inherently None-tolerant (`Value::None` is just one of
4000    // the polymorphic variants). Both opt the node out of the
4001    // kernel-Rule-1 short-circuit.
4002    let has_none_aware_arg = args.iter().any(|a| match &a.kind {
4003        ArgKind::Wire => is_option_arg(&a.declared_ty),
4004        ArgKind::PolyWire => true,
4005        _ => false,
4006    });
4007    let accepts_none_impl: TokenStream2 = if has_none_aware_arg {
4008        quote! {
4009            fn accepts_none_inputs(&self) -> bool { true }
4010        }
4011    } else {
4012        quote!()
4013    };
4014
4015    // SRD-80b Phase C — `Const<Vec<C>>` implies
4016    // `Arity::VariadicConsts`. Mutually exclusive with the
4017    // wire-variadic case (the macro rejects mixing them earlier).
4018    let has_const_vec = args.iter().any(|a| matches!(a.kind, ArgKind::ConstVec(_)));
4019    let arity_field: TokenStream2 = if has_variadic {
4020        // SRD-80b split-halves: `variadic_min` is interpreted
4021        // as PAIRS count; the FuncSig advertises 2× as total
4022        // wires so the assembler enforces the right floor.
4023        let min_wires = match (&attrs.variadic_min, is_split_halves) {
4024            (Some(v), true) => quote!(2 * (#v)),
4025            (Some(v), false) => quote!(#v),
4026            (None, _) => quote!(0),
4027        };
4028        quote!(polydat::dsl::registry::Arity::VariadicWires { min_wires: #min_wires })
4029    } else if has_const_vec {
4030        // min_consts = 0 by default; the workload-list shape
4031        // permits empty lists. Authors who want a minimum
4032        // declare it via `#[poly_default]` on the inner type or
4033        // by validating in the body.
4034        quote!(polydat::dsl::registry::Arity::VariadicConsts { min_consts: 0 })
4035    } else {
4036        quote!(polydat::dsl::registry::Arity::Fixed)
4037    };
4038
4039    let commutativity_field: TokenStream2 = if let Some(c) = &attrs.commutativity {
4040        quote!(polydat::ast::Commutativity::#c)
4041    } else {
4042        quote!(polydat::ast::Commutativity::Positional)
4043    };
4044
4045    // SRD-80b Phase 5 S16 — fallible-mode emission. When the body
4046    // returns Result<T, E>, the macro:
4047    //   * adds a cached `__polydat_cached: T` struct field,
4048    //   * replaces `new(...)` with `try_new(...) -> Result<Self, String>`,
4049    //   * runs the body once inside try_new, captures Ok into the
4050    //     cache, propagates Err via Into<String>,
4051    //   * makes eval read the cached value (no per-eval body call).
4052    let ctor_doc = format!("A `{func_name_str}` node with the given constant arguments.");
4053    let (ctor_emission, eval_emission, build_call_emission): (
4054        TokenStream2,
4055        TokenStream2,
4056        TokenStream2,
4057    ) = if is_fallible {
4058        // body-arg pass list. In try_new() Const args arrive as
4059        // their `field_type_tokens()` form (String for Str, raw
4060        // primitive otherwise) and need wrapping as `Const<T>` for
4061        // the body's declared signature. Setup args are locals
4062        // produced by `setup_precomputes` — body takes `&local`.
4063        let body_arg_passes: Vec<TokenStream2> = args
4064            .iter()
4065            .map(|a| {
4066                let n = &a.name;
4067                match &a.kind {
4068                    ArgKind::Const(shape) => shape.wrap_as_const(quote!(#n)),
4069                    ArgKind::Setup(_) => quote!(&#n),
4070                    // Wire / PolyWire / Variadic are rejected
4071                    // earlier for fallible nodes — unreachable.
4072                    _ => quote!(#n),
4073                }
4074            })
4075            .collect();
4076        // Local wrapping: each Const arg comes in as the wrapper
4077        // (matching new_params), so we forward it directly. The
4078        // body receives `Const<T>` and unwraps via .0 or .as_str()
4079        // in its own code.
4080        let try_new = quote! {
4081            #[doc = #ctor_doc]
4082            pub fn try_new( #( #new_params ),* ) -> ::std::result::Result<Self, String> {
4083                #( #setup_precomputes )*
4084                let mut ins: Vec<polydat::ast::Slot> = vec![ #( #slot_exprs ),* ];
4085                #( #variadic_slot_extends )*
4086                #outs_build
4087                // Invoke the body once; propagate Err as String.
4088                let __polydat_cached = match Self::__polydat_body( #( #body_arg_passes ),* ) {
4089                    Ok(v) => v,
4090                    Err(e) => return Err(Into::<String>::into(e)),
4091                };
4092                Ok(Self {
4093                    meta: polydat::ast::NodeMeta {
4094                        name: #func_name_str.into(),
4095                        ins,
4096                        outs,
4097                    },
4098                    #( #new_field_inits, )*
4099                    __polydat_cached,
4100                })
4101            }
4102        };
4103        // eval reads the cached value; no body call.
4104        let out_assign = output_assign(quote!(0), &ret_ty, quote!(self.__polydat_cached.clone()));
4105        let ev = quote! {
4106            #[allow(unused_variables)]
4107            { #out_assign }
4108        };
4109        // build closure: call try_new and propagate Err.
4110        let bc = quote! {
4111            Some(match #struct_name::try_new( #( #new_call_args ),* ) {
4112                Ok(n) => Ok(Box::new(n) as Box<dyn polydat::ast::PolydatNode>),
4113                Err(e) => Err(e),
4114            })
4115        };
4116        (try_new, ev, bc)
4117    } else {
4118        let ctor = quote! {
4119            #[doc = #ctor_doc]
4120            pub fn new( #( #new_params ),* ) -> Self {
4121                // SRD-80 PR B.6: setup pre-computes (FnOnce-
4122                // equivalent — emitted once by the macro,
4123                // never reachable by any other code path).
4124                #( #setup_precomputes )*
4125                // Build the `ins` slot list. Const args and
4126                // singleton wires already appear in `slot_exprs`;
4127                // variadic args append N slots per `n_wires`.
4128                let mut ins: Vec<polydat::ast::Slot> = vec![ #( #slot_exprs ),* ];
4129                #( #variadic_slot_extends )*
4130                #outs_build
4131                Self {
4132                    meta: polydat::ast::NodeMeta {
4133                        name: #func_name_str.into(),
4134                        ins,
4135                        outs,
4136                    },
4137                    #( #new_field_inits, )*
4138                }
4139            }
4140        };
4141        let ev = quote!(#eval_body);
4142        // Wrap `new()` in `catch_unwind` so that panics from
4143        // `#[poly_const]` setup functions (Regex parse failures,
4144        // file-not-found from filename consts, "value:weight"
4145        // parse failures, etc.) surface as build-closure `Err`
4146        // values rather than unwinding through the compile path.
4147        // The runtime sees `name` here as the DSL-registered
4148        // function name; the message is prefixed for traceability.
4149        let bc = quote! {
4150            Some(match ::std::panic::catch_unwind(
4151                ::std::panic::AssertUnwindSafe(|| #struct_name::new( #( #new_call_args ),* ))
4152            ) {
4153                Ok(node) => Ok(Box::new(node) as Box<dyn polydat::ast::PolydatNode>),
4154                Err(panic) => {
4155                    let msg = panic.downcast_ref::<&str>().copied()
4156                        .or_else(|| panic.downcast_ref::<String>().map(|s| s.as_str()))
4157                        .unwrap_or("<non-string panic>");
4158                    Err(format!("{}: construction failed: {}", #func_name_str, msg))
4159                }
4160            })
4161        };
4162        (ctor, ev, bc)
4163    };
4164
4165    // Cached field for fallible mode. T = `ret_ty` (the Ok inner).
4166    let cached_field: TokenStream2 = if is_fallible {
4167        quote!(__polydat_cached: #ret_ty,)
4168    } else {
4169        quote!()
4170    };
4171
4172    // The node's documentation: the function's own doc comments on the
4173    // struct the macro generates, or a line naming the node, and a line
4174    // for the constructor, so a generated node is documented as the
4175    // function that defines it is. The same text fills the registered
4176    // signature: the first paragraph is its `description`, the rest
4177    // its `help`.
4178    let fn_docs: Vec<&syn::Attribute> = func
4179        .attrs
4180        .iter()
4181        .filter(|a| a.path().is_ident("doc"))
4182        .collect();
4183    let struct_doc = if fn_docs.is_empty() {
4184        let text = format!("The `{func_name_str}` node.");
4185        quote! { #[doc = #text] }
4186    } else {
4187        quote! { #( #fn_docs )* }
4188    };
4189    let (description, help) = doc_text(&fn_docs);
4190    let description_lit = syn::LitStr::new(&description, proc_macro2::Span::call_site());
4191    let help_lit = syn::LitStr::new(&help, proc_macro2::Span::call_site());
4192    let result = quote! {
4193        #struct_doc
4194        pub struct #struct_name {
4195            meta: polydat::ast::NodeMeta,
4196            #( #struct_fields, )*
4197            #cached_field
4198        }
4199
4200        #default_impl
4201
4202        #fused_node_impl
4203
4204        impl #struct_name {
4205            #ctor_emission
4206
4207            // SRD-80 PR B.7: shared `__polydat_body` extracted
4208            // when the node is JIT-eligible. Both `eval()` and
4209            // `compiled_u64()` call it. Empty token stream when
4210            // JIT is not emitted (body stays inlined in eval).
4211            #body_fn_def
4212        }
4213
4214        impl polydat::ast::PolydatNode for #struct_name {
4215            fn meta(&self) -> &polydat::ast::NodeMeta { &self.meta }
4216
4217            fn eval(
4218                &self,
4219                inputs: &[polydat::ast::Value],
4220                outputs: &mut [polydat::ast::Value],
4221            ) {
4222                #eval_emission
4223            }
4224
4225            #state_impl
4226            #compiled_u64_impl
4227            #compiled_slot_impl
4228            #jit_constants_impl
4229            #purity_impl
4230            #simd_variant_impl
4231            #accepts_none_impl
4232        }
4233
4234        // SRD-80 PR B.2/B.3/B.5 — link-time registration via
4235        // the existing `NodeRegistration` inventory channel.
4236        // The build closure pulls const args from the runtime
4237        // `consts` slice, falling back to per-arg
4238        // `#[poly_default(...)]` values if the slice is short.
4239        const _: () = {
4240            static SIGS: &[polydat::dsl::registry::FuncSig] = &[
4241                polydat::dsl::registry::FuncSig {
4242                    name: #func_name_str,
4243                    category: polydat::dsl::registry::FuncCategory::#category,
4244                    outputs: #output_count_lit,
4245                    description: #description_lit,
4246                    help: #help_lit,
4247                    identity: #identity_field,
4248                    variadic_ctor: #variadic_ctor_field,
4249                    params: &[ #( #param_specs ),* ],
4250                    arity: #arity_field,
4251                    commutativity: #commutativity_field,
4252                    default_resolver: #default_resolver_field,
4253                    output_type: #output_type_tokens,
4254                    output_port: #output_port_field,
4255                },
4256            ];
4257
4258            fn signatures() -> &'static [polydat::dsl::registry::FuncSig] { SIGS }
4259
4260            fn build(
4261                name: &str,
4262                _wires: &[polydat::compile::assembly::WireRef],
4263                _wire_types: &[polydat::ast::PortType],
4264                consts: &[polydat::dsl::factory::ConstArg],
4265            ) -> Option<Result<Box<dyn polydat::ast::PolydatNode>, String>> {
4266                if name != #func_name_str { return None; }
4267                #( #const_extracts )*
4268                #( #polywire_extracts )*
4269                #variadic_n_wires_extract
4270                #build_call_emission
4271            }
4272
4273            ::polydat::inventory::submit! {
4274                polydat::dsl::registry::NodeRegistration {
4275                    signatures,
4276                    build,
4277                    validate: None,
4278                }
4279            }
4280        };
4281    };
4282
4283    Ok(result)
4284}
4285
4286/// Split a function's `///` comments into the registered
4287/// `description` (the first paragraph, joined onto one line) and
4288/// `help` (every paragraph after it, lines kept). Each line loses
4289/// the one space rustdoc puts after `///`.
4290fn doc_text(doc_attrs: &[&syn::Attribute]) -> (String, String) {
4291    let mut lines: Vec<String> = Vec::new();
4292    for attr in doc_attrs {
4293        if let syn::Meta::NameValue(nv) = &attr.meta
4294            && let syn::Expr::Lit(syn::ExprLit {
4295                lit: syn::Lit::Str(s),
4296                ..
4297            }) = &nv.value
4298        {
4299            let raw = s.value();
4300            lines.push(raw.strip_prefix(' ').unwrap_or(&raw).to_string());
4301        }
4302    }
4303    while lines.first().is_some_and(|l| l.trim().is_empty()) {
4304        lines.remove(0);
4305    }
4306    while lines.last().is_some_and(|l| l.trim().is_empty()) {
4307        lines.pop();
4308    }
4309    let split = lines
4310        .iter()
4311        .position(|l| l.trim().is_empty())
4312        .unwrap_or(lines.len());
4313    let description = lines[..split]
4314        .iter()
4315        .map(|l| l.trim())
4316        .collect::<Vec<_>>()
4317        .join(" ");
4318    let rest = &lines[split..];
4319    let rest_start = rest
4320        .iter()
4321        .position(|l| !l.trim().is_empty())
4322        .unwrap_or(rest.len());
4323    let help = rest[rest_start..]
4324        .iter()
4325        .map(|l| l.trim_end())
4326        .collect::<Vec<_>>()
4327        .join("\n");
4328    (description, help)
4329}
4330
4331/// `snake_case` → `PascalCase` (for the generated struct name).
4332fn to_camel_case(s: &str) -> String {
4333    let mut out = String::with_capacity(s.len());
4334    let mut up = true;
4335    for c in s.chars() {
4336        if c == '_' {
4337            up = true;
4338            continue;
4339        }
4340        if up {
4341            out.extend(c.to_uppercase());
4342            up = false;
4343        } else {
4344            out.push(c);
4345        }
4346    }
4347    out
4348}
4349
4350/// Stringify a `syn::Type` minimally — used for primitive-type
4351/// dispatch. Not a robust pretty-printer; only handles the
4352/// shapes the simple-case allows (bare path, `&str`, `String`).
4353fn type_to_string(ty: &Type) -> String {
4354    use quote::ToTokens;
4355    let mut s = String::new();
4356    for t in ty.to_token_stream() {
4357        s.push_str(&t.to_string());
4358        s.push(' ');
4359    }
4360    s.trim().to_string()
4361}