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