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 ® {
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 ® {
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}