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