Skip to main content

polydat_core/dsl/
registry.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Function registry: known function signatures for DSL validation.
5//!
6//! Each registered function declares its name, category, expected wire
7//! inputs, constant parameters, output count, and variadic behavior.
8//! The compiler uses this to validate calls at parse time and to
9//! generically dispatch variadic functions.
10//!
11//! Categories are a type-safe enum — every function must declare one.
12//! Categories group the registry for listings (`by_category`). The
13//! stdlib files carry a `// @category: Name` line for readers;
14//! `FuncCategory::parse` maps that text but no compile path consumes
15//! it.
16//!
17//! Signatures are owned by their respective node modules. This file
18//! defines the shared types and the collector function.
19
20pub use crate::ast::CompileLevel;
21use crate::compile::assembly::WireRef;
22use crate::dsl::factories::NodeFactory;
23
24/// Builder for a node module: `(build context, name, wires, resolved
25/// wire port types, const args) -> Some(Ok(node)) / Some(Err(msg))`,
26/// or `None` when the name isn't handled by this module. The build
27/// context carries the bindings under construction and the program
28/// tree's resource scope ([`crate::dsl::factory::BuildContext`]).
29pub type NodeBuildFn = fn(
30    &crate::dsl::factory::BuildContext,
31    &str,
32    &[WireRef],
33    &[crate::ast::PortType],
34    &[crate::dsl::factory::ConstArg],
35) -> Option<Result<Box<dyn crate::ast::PolydatNode>, String>>;
36
37/// A node module's registration: signatures + builder.
38///
39/// Each node module submits one of these at link time via `inventory::submit!`.
40/// The runtime collects all submissions to build the function registry and
41/// dispatch table without any explicit module list.
42///
43/// A registration is a [`NodeFactory`]: its `build` is the factory's
44/// body, and the registry builds its nodes through that factory as it
45/// builds every other node ([`crate::dsl::factory::build_node`]).
46pub struct NodeRegistration {
47    /// Returns the static slice of `FuncSig` entries for this module.
48    pub signatures: fn() -> &'static [FuncSig],
49    /// Builds a node for the given function name: the body of this
50    /// registration's [`NodeFactory::build`].
51    ///
52    /// Returns `Some(Ok(node))` / `Some(Err(msg))` for a name it
53    /// builds, and `None` for one it leaves to its signature: a
54    /// variadic function then gets its signature's identity element or
55    /// variadic constructor, and any other name is an unknown function.
56    ///
57    /// `wire_types[i]` is the resolved [`crate::ast::PortType`] of
58    /// `wires[i]` — the output type of the upstream node feeding
59    /// that wire input. Modules that build type-polymorphic nodes
60    /// (e.g. `log_info`, whose output type equals its input type)
61    /// read this to construct the node with the correct port
62    /// types. Modules whose nodes have type-fixed signatures can
63    /// ignore the slice. When the assembler can't resolve a
64    /// wire's type (forward reference, dangling), the slot
65    /// defaults to [`crate::ast::PortType::U64`].
66    pub build: NodeBuildFn,
67    /// Optional assembly-time validator for this module's constants.
68    ///
69    /// The factory calls this **before** `build` whenever the name
70    /// matches one of this module's functions. Returning `Err` makes
71    /// the compile fail with a structured `bad constant` error, so
72    /// the node itself never sees a malformed literal and can keep
73    /// its constructor and `eval()` branch-free.
74    pub validate: Option<crate::dsl::const_constraints::NodeValidator>,
75}
76
77inventory::collect!(NodeRegistration);
78
79impl NodeFactory for NodeRegistration {
80    fn signatures(&self) -> &[FuncSig] {
81        (self.signatures)()
82    }
83
84    fn validate(&self, name: &str, consts: &[crate::dsl::factory::ConstArg]) -> Result<(), String> {
85        match self.validate {
86            Some(validator) => validator(name, consts),
87            None => Ok(()),
88        }
89    }
90
91    /// The registered builder's node. A builder that declines a name its
92    /// signatures list leaves a variadic function to its signature: the
93    /// identity element for no wires, the variadic constructor otherwise.
94    fn build(
95        &self,
96        ctx: &crate::dsl::factory::BuildContext,
97        name: &str,
98        wires: &[WireRef],
99        wire_types: &[crate::ast::PortType],
100        consts: &[crate::dsl::factory::ConstArg],
101    ) -> Result<Box<dyn crate::ast::PolydatNode>, String> {
102        if let Some(result) = (self.build)(ctx, name, wires, wire_types, consts) {
103            return result;
104        }
105        let sig = (self.signatures)().iter().find(|s| s.name == name);
106        match sig {
107            Some(sig) => crate::dsl::factory::variadic_node(sig, wires.len()),
108            None => Err(format!("unknown function: '{name}'")),
109        }
110    }
111}
112
113/// A host's own [`NodeFactory`], linked into the registry.
114///
115/// A host submits one at link time, as a node module submits its
116/// [`NodeRegistration`]:
117///
118/// ```ignore
119/// static FACTORY: MyFactory = MyFactory;
120/// polydat::inventory::submit! {
121///     polydat::dsl::registry::FactoryRegistration { factory: &FACTORY }
122/// }
123/// ```
124///
125/// The registry lists the factory's signatures and builds every call to
126/// one of them through its `build`, on every engine.
127pub struct FactoryRegistration {
128    /// The factory.
129    pub factory: &'static dyn NodeFactory,
130}
131
132inventory::collect!(FactoryRegistration);
133
134/// Every factory linked into the registry: each node module's
135/// [`NodeRegistration`], then each host [`FactoryRegistration`]. A
136/// function name belongs to the first factory whose signatures list it.
137pub fn factories() -> impl Iterator<Item = &'static dyn NodeFactory> {
138    inventory::iter::<NodeRegistration>
139        .into_iter()
140        .map(|reg| reg as &'static dyn NodeFactory)
141        .chain(
142            inventory::iter::<FactoryRegistration>
143                .into_iter()
144                .map(|reg| reg.factory),
145        )
146}
147
148/// Register a node module's signatures and builder with the Polydat runtime.
149///
150/// Place this call at module scope in each node module. The inventory crate
151/// arranges for the registration to run before `main` so that `registry()`
152/// and `build_node()` see all entries.
153///
154/// Two forms:
155///
156/// - `register_nodes!(signatures, build_node)` — no assembly-time
157///   validation. The builder is responsible for handling any bad
158///   input itself (usually by trusting the caller or panicking).
159/// - `register_nodes!(signatures, build_node, validate_node)` — the
160///   factory calls `validate_node(name, consts)` before `build_node`.
161///   Use this to declare [`ConstConstraint`]-style checks so
162///   constructors can stay infallible.
163///
164/// [`ConstConstraint`]: crate::dsl::const_constraints::ConstConstraint
165#[macro_export]
166macro_rules! register_nodes {
167    ($sigs:expr, $builder:expr) => {
168        inventory::submit! {
169            $crate::dsl::registry::NodeRegistration {
170                signatures: $sigs,
171                build: $builder,
172                validate: None,
173            }
174        }
175    };
176    ($sigs:expr, $builder:expr, $validator:expr) => {
177        inventory::submit! {
178            $crate::dsl::registry::NodeRegistration {
179                signatures: $sigs,
180                build: $builder,
181                validate: Some($validator),
182            }
183        }
184    };
185}
186
187/// Functional category for a Polydat node function.
188///
189/// Every native node and stdlib module belongs to exactly one category.
190/// Categories group the registry for listings (`by_category`) and provide
191/// semantic organization for documentation and discovery.
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
193pub enum FuncCategory {
194    /// Core deterministic hashing.
195    Hashing,
196    /// Integer arithmetic with constant parameters.
197    Arithmetic,
198    /// Comparison and selection: ==, !=, <, >, <=, >=, if(...).
199    /// Comparison nodes return u64 truth values (0 or 1); select
200    /// nodes pick between two operand values based on a u64 cond.
201    Comparison,
202    /// Variadic N-ary operations (sum, product, min, max).
203    Variadic,
204    /// Type conversions between u64, f64, String, etc.
205    Conversions,
206    /// Statistical distribution LUT builders and samplers.
207    Distributions,
208    /// Date and time generation and decomposition.
209    Datetime,
210    /// HTML, URL, hex, base64 encoding/decoding.
211    Encoding,
212    /// Linear interpolation, range mapping, quantization.
213    Interpolation,
214    /// Trigonometric and mathematical functions (sin, cos, sqrt, etc.).
215    Math,
216    /// Probability modeling: coins, selection, conditionals.
217    Probability,
218    /// Weighted categorical selection.
219    Weighted,
220    /// Printf-style and structured string formatting.
221    Formatting,
222    /// String generation: combinations, number words.
223    String,
224    /// JSON construction, serialization, merging.
225    Json,
226    /// Byte buffer construction and manipulation.
227    ByteBuffers,
228    /// Cryptographic and non-cryptographic digests.
229    Digest,
230    /// Coherent noise: Perlin, simplex.
231    Noise,
232    /// Regular expression matching and substitution.
233    Regex,
234    /// Bijective permutations and shuffles.
235    Permutation,
236    /// Real-world data: names, places, codes.
237    RealData,
238    /// Non-deterministic context: wall clock, counters.
239    Context,
240    /// Debugging and introspection.
241    Diagnostic,
242    /// File-based data access: CSV, JSONL, text files.
243    Data,
244}
245
246impl FuncCategory {
247    /// Display name for the category (used in describe output).
248    pub fn display_name(&self) -> &'static str {
249        match self {
250            Self::Hashing => "Hashing",
251            Self::Arithmetic => "Arithmetic",
252            Self::Comparison => "Comparison",
253            Self::Variadic => "Variadic",
254            Self::Conversions => "Conversions",
255            Self::Distributions => "Distributions",
256            Self::Datetime => "Datetime",
257            Self::Encoding => "Encoding",
258            Self::Interpolation => "Interpolation",
259            Self::Math => "Math",
260            Self::Probability => "Probability",
261            Self::Weighted => "Weighted",
262            Self::Formatting => "Formatting",
263            Self::String => "String",
264            Self::Json => "JSON",
265            Self::ByteBuffers => "Byte Buffers",
266            Self::Digest => "Digest",
267            Self::Noise => "Noise",
268            Self::Regex => "Regex",
269            Self::Permutation => "Permutation",
270            Self::RealData => "Real Data",
271            Self::Context => "Context",
272            Self::Diagnostic => "Diagnostic",
273            Self::Data => "Data",
274        }
275    }
276
277    /// Parse a category name from a string (case-insensitive): the
278    /// text of a stdlib file's `// @category: Name` line. No compile
279    /// path consumes it.
280    pub fn parse(s: &str) -> Option<Self> {
281        match s.trim().to_lowercase().as_str() {
282            "hashing" => Some(Self::Hashing),
283            "arithmetic" => Some(Self::Arithmetic),
284            "comparison" | "compare" => Some(Self::Comparison),
285            "variadic" => Some(Self::Variadic),
286            "conversions" | "conversion" => Some(Self::Conversions),
287            "distributions" | "distribution" => Some(Self::Distributions),
288            "datetime" | "date" | "time" => Some(Self::Datetime),
289            "encoding" => Some(Self::Encoding),
290            "interpolation" | "lerp" => Some(Self::Interpolation),
291            "math" | "trig" | "trigonometry" => Some(Self::Math),
292            "probability" => Some(Self::Probability),
293            "weighted" => Some(Self::Weighted),
294            "formatting" | "format" | "printf" => Some(Self::Formatting),
295            "string" | "strings" => Some(Self::String),
296            "json" => Some(Self::Json),
297            "byte buffers" | "bytebuffers" | "bytes" => Some(Self::ByteBuffers),
298            "digest" => Some(Self::Digest),
299            "noise" => Some(Self::Noise),
300            "regex" => Some(Self::Regex),
301            "permutation" | "shuffle" => Some(Self::Permutation),
302            "real data" | "realdata" | "realer" => Some(Self::RealData),
303            "context" => Some(Self::Context),
304            "diagnostic" | "diagnostics" | "debug" => Some(Self::Diagnostic),
305            "data" | "datafile" | "csv" | "jsonl" => Some(Self::Data),
306            _ => None,
307        }
308    }
309
310    /// Canonical ordering for display (same order as the enum definition).
311    pub fn display_order() -> &'static [Self] {
312        &[
313            Self::Hashing,
314            Self::Arithmetic,
315            Self::Comparison,
316            Self::Variadic,
317            Self::Conversions,
318            Self::Distributions,
319            Self::Datetime,
320            Self::Encoding,
321            Self::Interpolation,
322            Self::Math,
323            Self::Probability,
324            Self::Weighted,
325            Self::Formatting,
326            Self::String,
327            Self::Json,
328            Self::ByteBuffers,
329            Self::Digest,
330            Self::Noise,
331            Self::Regex,
332            Self::Permutation,
333            Self::RealData,
334            Self::Context,
335            Self::Diagnostic,
336            Self::Data,
337        ]
338    }
339}
340
341// ---------------------------------------------------------------------------
342// Unified parameter specification (library_catalog.md, "Shapes")
343// ---------------------------------------------------------------------------
344
345use crate::ast::SlotType;
346
347/// Describes one parameter in a function's call signature.
348///
349/// A "slot template" — the type-level version of a `Slot` without
350/// a concrete value. Parameters are listed in positional order
351/// matching the DSL syntax.
352#[derive(Debug, Clone, Copy)]
353pub struct ParamSpec {
354    /// Parameter name (for error messages and describe output).
355    pub name: &'static str,
356    /// Wire or constant, and if constant, what type.
357    pub slot_type: SlotType,
358    /// Whether this parameter must be provided.
359    pub required: bool,
360    /// Example value for this parameter, as program text, used for
361    /// probing compile level and for documentation. Wire params use
362    /// `"cycle"`; a const param with a declared default uses that
363    /// default, which passes validation; a const param without one is
364    /// empty, since the signature offers no value to show.
365    pub example: &'static str,
366    /// Optional assembly-time validation rule. The factory enforces this before
367    /// `build_node` so node constructors can stay infallible and
368    /// branch-free at runtime. `None` = no constraint declared
369    /// (default for wires and unconstrained constants).
370    pub constraint: Option<crate::dsl::const_constraints::ConstConstraint>,
371}
372
373impl ParamSpec {
374    /// Convenience: chainable on a literal to attach a constraint.
375    /// Used by node modules that want to keep the literal compact.
376    pub const fn with_constraint(
377        mut self,
378        c: crate::dsl::const_constraints::ConstConstraint,
379    ) -> Self {
380        self.constraint = Some(c);
381        self
382    }
383}
384
385/// Arity specification for a function signature.
386///
387/// Describes which parts of the parameter list are fixed vs repeatable.
388#[derive(Debug, Clone, Default)]
389pub enum Arity {
390    /// Exactly the parameters declared in `params`.
391    #[default]
392    Fixed,
393    /// Trailing wire parameters repeat (sum, product, min, max).
394    VariadicWires {
395        /// The fewest trailing wires allowed.
396        min_wires: usize,
397    },
398    /// Trailing constant parameters repeat (mixed_radix).
399    VariadicConsts {
400        /// The fewest trailing constants allowed.
401        min_consts: usize,
402    },
403    /// A repeating group of slot types (weighted_sum).
404    VariadicGroup {
405        /// The slot types of one repetition, in order.
406        group: &'static [SlotType],
407        /// The fewest repetitions allowed.
408        min_repeats: usize,
409    },
410}
411
412/// Output-type contract for a registered function.
413///
414/// Most nodes have fixed port types declared by their constructor's
415/// `NodeMeta`. Some — `log_info`, `identity`, anything documented
416/// as "pass-through" — produce an output whose type matches one
417/// of their inputs. Declaring this here makes the contract visible
418/// to the assembler, the build-node dispatch path, registry
419/// listings, and any future static analysis, instead of being
420/// buried inside an `eval` that silently passes values through a
421/// wire whose declared type lies.
422#[derive(Debug, Clone, Copy, PartialEq, Eq)]
423pub enum OutputType {
424    /// Output types are whatever the constructor's `NodeMeta`
425    /// declares — independent of input wire types. The default;
426    /// covers the vast majority of nodes (`hash`, `regex_match`,
427    /// `mod`, …) whose I/O contract is type-fixed.
428    Fixed,
429    /// The function's single output port has the same type as the
430    /// input wire at the given index. The build dispatch resolves
431    /// the wire's type from the assembler and the module's
432    /// `build_node` reads it from the supplied `wire_types` slice
433    /// to construct a port-typed node. Used by pass-through
434    /// nodes (`log_info`, `log_debug`, …).
435    SameAsInput(usize),
436}
437
438/// Description of a registered function's signature.
439pub struct FuncSig {
440    /// Function name as used in the DSL.
441    pub name: &'static str,
442    /// Functional category.
443    pub category: FuncCategory,
444    /// Number of output ports (0 = dynamic, determined at compile time).
445    pub outputs: usize,
446    /// Short description for help/error messages.
447    pub description: &'static str,
448    /// Detailed help text: theory, usage examples, parameter meanings.
449    /// Long-form help text for listings and documentation.
450    pub help: &'static str,
451    /// For variadic functions: the identity element for zero inputs.
452    pub identity: Option<u64>,
453    /// Factory for variadic nodes: takes wire count, returns node.
454    pub variadic_ctor: Option<fn(usize) -> Box<dyn crate::ast::PolydatNode>>,
455    /// Positional parameter list: wires and constants in call order.
456    pub params: &'static [ParamSpec],
457    /// Arity specification.
458    pub arity: Arity,
459    /// Input commutativity for this function.
460    pub commutativity: crate::ast::Commutativity,
461    /// Optional resolver hint for `Handle`-typed input ports. When
462    /// the binding compiler emits this function and a `Handle`
463    /// input is wired to a `Str`-producing source, it splices in
464    /// the named resolver to convert the string into a handle. This
465    /// is the "string-conversion node insertion" mechanism of
466    /// source-string call-site sugar. `None` means no
467    /// auto-promotion — the caller must pass a `Handle` directly.
468    pub default_resolver: Option<DefaultResolver>,
469    /// Output-type contract — `Fixed` for the vast majority of
470    /// nodes; `SameAsInput(idx)` for type-polymorphic pass-throughs
471    /// (e.g. `log_info` whose output type tracks its sole input).
472    pub output_type: OutputType,
473    /// Concrete port type of the single output, when statically
474    /// known (`#[polydat_node]` emits it from the return type's
475    /// `Wire::PORT`). `None` for tuple/dynamic/polymorphic outputs
476    /// and for hand registrations that don't declare one. The DSL
477    /// type inference (`binding::infer_expr_type`) reads this
478    /// FIRST — the name-prefix heuristic is only the fallback —
479    /// so call-expression operand typing flows from the symbol
480    /// registry, not from a hand-maintained list.
481    pub output_port: Option<crate::ast::PortType>,
482}
483
484/// Auto-resolver kind attached to handle-taking functions. Tells the
485/// binding compiler which resolver to splice in when a string source
486/// is wired to a handle input port.
487#[derive(Debug, Clone, Copy)]
488pub enum DefaultResolver {
489    /// Insert `dataset_open(<source_wire>, "<facet>")` between the
490    /// string source and the handle input.
491    Facet(&'static str),
492    /// Insert `dataset_group_open(<source_wire>)` between the string
493    /// source and the handle input.
494    Group,
495}
496
497impl FuncSig {
498    /// Number of wire inputs in the fixed parameter list.
499    pub fn wire_input_count(&self) -> usize {
500        self.params.iter().filter(|p| p.slot_type.is_wire()).count()
501    }
502
503    /// Whether this function accepts variadic arguments.
504    pub fn is_variadic(&self) -> bool {
505        !matches!(self.arity, Arity::Fixed)
506    }
507
508    /// Constant parameter names and whether they're required.
509    pub fn const_param_info(&self) -> Vec<(&'static str, bool)> {
510        self.params
511            .iter()
512            .filter(|p| p.slot_type.is_const())
513            .map(|p| (p.name, p.required))
514            .collect()
515    }
516}
517
518impl std::fmt::Debug for FuncSig {
519    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
520        f.debug_struct("FuncSig")
521            .field("name", &self.name)
522            .field("category", &self.category)
523            .field("params", &self.params)
524            .field("arity", &self.arity)
525            .finish()
526    }
527}
528
529impl Clone for FuncSig {
530    fn clone(&self) -> Self {
531        Self {
532            name: self.name,
533            category: self.category,
534            outputs: self.outputs,
535            description: self.description,
536            help: self.help,
537            identity: self.identity,
538            variadic_ctor: self.variadic_ctor,
539            params: self.params,
540            arity: self.arity.clone(),
541            commutativity: self.commutativity.clone(),
542            default_resolver: self.default_resolver,
543            output_type: self.output_type,
544            output_port: self.output_port,
545        }
546    }
547}
548
549/// Return the full registry of known functions: the signatures of every
550/// linked factory ([`factories`]).
551pub fn registry() -> Vec<FuncSig> {
552    let mut funcs = Vec::new();
553    for factory in factories() {
554        funcs.extend_from_slice(factory.signatures());
555    }
556    funcs
557}
558
559/// Return functions grouped by category in display order.
560pub fn by_category() -> Vec<(FuncCategory, Vec<FuncSig>)> {
561    let reg = registry();
562    let mut groups: std::collections::HashMap<FuncCategory, Vec<FuncSig>> =
563        std::collections::HashMap::new();
564    for sig in reg {
565        groups.entry(sig.category).or_default().push(sig);
566    }
567    FuncCategory::display_order()
568        .iter()
569        .filter_map(|cat| groups.remove(cat).map(|funcs| (*cat, funcs)))
570        .collect()
571}
572
573/// Find the closest function name to a misspelling.
574pub fn suggest_function(name: &str) -> Option<&'static str> {
575    let reg = registry();
576    let mut best: Option<(&str, usize)> = None;
577    for sig in &reg {
578        let dist = edit_distance(name, sig.name);
579        if dist <= 3 && (best.is_none() || dist < best.unwrap().1) {
580            best = Some((sig.name, dist));
581        }
582    }
583    best.map(|(name, _)| name)
584}
585
586/// Find a registered function by name: the signature of the factory
587/// that builds it. Every factory is `'static`, so its signatures are
588/// too, and no allocation is needed.
589pub fn lookup(name: &str) -> Option<&'static FuncSig> {
590    factories().find_map(|factory| factory.signatures().iter().find(|sig| sig.name == name))
591}
592
593fn edit_distance(a: &str, b: &str) -> usize {
594    let a: Vec<char> = a.chars().collect();
595    let b: Vec<char> = b.chars().collect();
596    let mut matrix = vec![vec![0usize; b.len() + 1]; a.len() + 1];
597    for (i, row) in matrix.iter_mut().enumerate() {
598        row[0] = i;
599    }
600    for (j, cell) in matrix[0].iter_mut().enumerate() {
601        *cell = j;
602    }
603    for i in 1..=a.len() {
604        for j in 1..=b.len() {
605            let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
606            matrix[i][j] = (matrix[i - 1][j] + 1)
607                .min(matrix[i][j - 1] + 1)
608                .min(matrix[i - 1][j - 1] + cost);
609        }
610    }
611    matrix[a.len()][b.len()]
612}
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617
618    #[test]
619    fn suggest_no_match() {
620        assert_eq!(suggest_function("zzzzzzzzz"), None);
621    }
622
623    #[test]
624    fn lookup_missing() {
625        assert!(lookup("nonexistent").is_none());
626    }
627
628    #[test]
629    fn every_function_has_category() {
630        let reg = registry();
631        for sig in &reg {
632            // Just verify the category display name is non-empty
633            assert!(
634                !sig.category.display_name().is_empty(),
635                "function '{}' has no category display name",
636                sig.name
637            );
638        }
639    }
640
641    #[test]
642    fn by_category_covers_all() {
643        let grouped = by_category();
644        let total: usize = grouped.iter().map(|(_, funcs)| funcs.len()).sum();
645        let reg = registry();
646        assert_eq!(
647            total,
648            reg.len(),
649            "by_category must cover all registered functions"
650        );
651    }
652
653    #[test]
654    fn category_parse_roundtrip() {
655        for cat in FuncCategory::display_order() {
656            let name = cat.display_name();
657            let parsed = FuncCategory::parse(name);
658            assert_eq!(parsed, Some(*cat), "failed to parse category '{name}'");
659        }
660    }
661
662    #[test]
663    fn registry_has_entries() {
664        let reg = registry();
665        assert!(reg.len() > 50, "registry should have 50+ functions");
666    }
667
668    // --- Unified param model tests ---
669
670    #[test]
671    fn printf_has_const_str_param() {
672        // printf is a `#[polydat_node]` with a `Const<&str> format`
673        // arg + `&[Value] parts` variadic. The macro lists both in
674        // `params` (the variadic arg appears as a SlotType::Wire
675        // entry whose count is governed by the node's
676        // `Arity::VariadicWires`). The load-bearing assertion is that
677        // the FIRST param is the format ConstStr and the arity is
678        // variadic.
679        let sig = lookup("printf").unwrap();
680        assert!(
681            !sig.params.is_empty(),
682            "printf must have at least the format param"
683        );
684        assert!(matches!(sig.params[0].slot_type, SlotType::ConstStr));
685        assert!(matches!(sig.arity, Arity::VariadicWires { .. }));
686    }
687}