polydat-core 0.5.1

Polydat runtime: value model, graph compiler, execution engines, kernels
Documentation
// Copyright 2024-2026 Jonathan Shook
// SPDX-License-Identifier: Apache-2.0

//! Node factory: maps Polydat function names to runtime node instances.
//!
//! `build_node` is the single dispatch point used by the compiler's
//! `compile_binding` to turn a parsed call expression into a `Box<dyn PolydatNode>`.
//! `ConstArg` captures assembly-time constant arguments extracted from the AST.
//!
//! Dispatch is decentralized: each node module exposes its own `build_node`
//! function returning `Option<Result<...>>`.  The top-level `build_node` here
//! tries each module in turn and falls back to the registry for variadic nodes.

use crate::ast::PolydatNode;
use crate::compile::assembly::WireRef;
use crate::library::identity::ConstU64;

use crate::dsl::registry;

/// Constant arguments extracted from the AST.
///
/// Holds assembly-time values (integers, floats, strings, float arrays)
/// that are baked into node constructors rather than passed as wire inputs.
///
/// `pub` visibility is required so that `NodeRegistration::build` function
/// pointers (which are `pub` fields) can name this type.
#[derive(Clone)]
pub enum ConstArg {
    /// An integer literal.
    Int(u64),
    /// A float literal.
    Float(f64),
    /// A string literal.
    Str(String),
    /// SRD-80b Phase C — workload-list const carrier for the
    /// `Const<Vec<C>>` shape. Each inner [`ConstArg`] is one
    /// element; the macro emits the walk over the list and the
    /// per-element extraction for the element type it read out of
    /// the signature.
    List(Vec<ConstArg>),
    /// A value the compiler built and hands the node as it is.
    ///
    /// The other variants are what a literal in the source parses to.
    /// This one is for what the compiler makes: a tile's skeleton with
    /// its projection bodies already lowered, for instance. It used to
    /// travel as a `Str` holding JSON, which the node parsed back —
    /// so a malformed payload was a panic at node construction rather
    /// than a compile error, the body's source lived in three places,
    /// and everything the compiler knew that JSON cannot carry was
    /// lost on the way.
    ///
    /// The receiving node names the concrete type, which
    /// [`ConstArg::as_opaque`] downcasts to.
    Opaque(std::sync::Arc<dyn std::any::Any + Send + Sync>),
}

impl std::fmt::Debug for ConstArg {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ConstArg::Int(v) => f.debug_tuple("Int").field(v).finish(),
            ConstArg::Float(v) => f.debug_tuple("Float").field(v).finish(),
            ConstArg::Str(s) => f.debug_tuple("Str").field(s).finish(),
            ConstArg::List(l) => f.debug_tuple("List").field(l).finish(),
            // A compiler-built value has no text form, and the point
            // of carrying it opaquely is that this layer does not know
            // what it is.
            ConstArg::Opaque(_) => f.write_str("Opaque(..)"),
        }
    }
}

impl ConstArg {
    /// The value as `T`, when this is an opaque const the compiler
    /// built and `T` is the type it built.
    pub fn as_opaque<T: std::any::Any + Send + Sync>(&self) -> Option<std::sync::Arc<T>> {
        match self {
            ConstArg::Opaque(v) => v.clone().downcast::<T>().ok(),
            _ => None,
        }
    }

    /// Return the value as a `u64`, or 0 if incompatible.
    pub fn as_u64(&self) -> u64 {
        match self {
            ConstArg::Int(v) => *v,
            _ => 0,
        }
    }

    /// Return the value as an `f64`, or 0.0 if incompatible.
    ///
    /// Integer literals are widened to f64.
    pub fn as_f64(&self) -> f64 {
        match self {
            ConstArg::Float(v) => *v,
            ConstArg::Int(v) => *v as f64,
            _ => 0.0,
        }
    }

    /// Return the value as a `&str`, or `""` if incompatible.
    pub fn as_str(&self) -> &str {
        match self {
            ConstArg::Str(s) => s,
            _ => "",
        }
    }
}

/// Source-binding attribution, set by the compiler before each
/// `build_node` call and read by factories that want to record
/// which DSL binding caused the node to exist. The
/// `control_set` factory is the canonical consumer:
/// `rate_adj := control_set("rate", target)` calls the factory
/// with `current_binding()` returning `"rate_adj"`, which the
/// node stores for runtime attribution in
/// `ControlOrigin::Polydat { binding }`.
pub mod compile_ctx {
    use std::cell::RefCell;
    thread_local! {
        static BINDING: RefCell<Option<String>> = const { RefCell::new(None) };
    }

    /// Install the current binding name for the duration of a
    /// single `build_node` call. Returns a guard that clears
    /// the thread-local on drop so nested compilation can't
    /// leak attribution across callers.
    pub fn scoped_binding(name: &str) -> BindingScope {
        BINDING.with(|b| *b.borrow_mut() = Some(name.to_string()));
        BindingScope(())
    }

    /// Read the current binding attribution. Returns `None`
    /// when called outside a [`scoped_binding`] scope (e.g.
    /// ad-hoc tests that call [`super::build_node`] directly).
    pub fn current_binding() -> Option<String> {
        BINDING.with(|b| b.borrow().clone())
    }

    /// RAII guard that clears the binding slot on drop.
    pub struct BindingScope(());
    impl Drop for BindingScope {
        fn drop(&mut self) {
            BINDING.with(|b| *b.borrow_mut() = None);
        }
    }
}

/// Build the node `func` takes for the given wires, their types, and
/// constant arguments, through the registry; an unknown function or a
/// mismatched signature is an error naming it.
///
/// Dispatch order: inventory registrations (constraint checks, then
/// the module validator, then `build`), then the registry's variadic
/// fallback.
pub fn build_node(
    func: &str,
    wires: &[WireRef],
    wire_types: &[crate::ast::PortType],
    consts: &[ConstArg],
) -> Result<Box<dyn PolydatNode>, String> {
    // --- Per-module dispatch via inventory ---

    use crate::dsl::registry::NodeRegistration;
    for reg in inventory::iter::<NodeRegistration> {
        // Only run this module's validator if it owns `func`.
        // Signatures are the authoritative "does this module
        // handle this name" list — probing `build` first would
        // invert the ordering (construction before validation)
        // and give an opt-in validator no chance to reject bad
        // constants before the constructor panics.
        let sigs = (reg.signatures)();
        let owning_sig = sigs.iter().find(|s| s.name == func);
        if owning_sig.is_none() {
            continue;
        }
        let sig = owning_sig.unwrap();

        // Pass 1: walk declared `ParamSpec.constraint`s and run
        // each per-param check. Constraints declared on individual
        // params cover the bulk of "must be in [0,1]" /
        // "must be one of {2,8,10,16}" / "spec must parse" cases.
        // SRD 15 §"Const Constraint Metadata".
        if let Err(msg) = check_param_constraints(sig, consts) {
            return Err(format!("bad constant {func}: {msg}"));
        }

        // Pass 2: per-module imperative validator for relational
        // and cross-param rules (e.g. `n_of`'s n ≤ m). Eventually
        // migrates onto a `FuncSig.validator` field; until then,
        // each module declares its relational constraint here.
        if let Some(validator) = reg.validate
            && let Err(reason) = validator(func, consts)
        {
            return Err(format!("bad constant {func}: {reason}"));
        }

        if let Some(result) = (reg.build)(func, wires, wire_types, consts) {
            return result;
        }
    }

    // --- Sampling functions without a dedicated node module ---
    //
    // `identity` migrated to `#[polydat_node]` per SRD-80 PR B.8.
    // `dist_*` / `icd_*` / `histribution` / `dist_empirical`
    // migrated to `#[polydat_node]` via `#[poly_const]` setup
    // (SRD-80b Phase E); the inventory-registered build closure
    // now handles each name, so the hand-dispatch arms here are
    // gone.

    // --- Registry variadic fallback ---
    if let Some(sig) = registry::lookup(func)
        && sig.is_variadic()
    {
        if wires.is_empty() {
            if let Some(id) = sig.identity {
                return Ok(Box::new(ConstU64::new(id)));
            }
            return Err(format!(
                "variadic function '{func}' requires at least one input"
            ));
        }
        if let Some(ctor) = sig.variadic_ctor {
            return Ok(ctor(wires.len()));
        }
    }

    let mut msg = format!("unknown function: '{func}'\n");
    if let Some(suggestion) = registry::suggest_function(func) {
        msg.push_str(&format!("\n  Did you mean '{suggestion}'?"));
    }
    msg.push_str("\n\n  This function is not registered in the wiring function library.");
    msg.push_str("\n  See the registered wiring functions for the available names.");
    Err(msg)
}

/// Walk `sig.params`, applying every declared `ConstConstraint`
/// to the corresponding positional `ConstArg`. Parameters with no
/// constraint are skipped; missing optional arguments are skipped
/// (the `required` flag handles mandatory presence elsewhere).
fn check_param_constraints(
    sig: &crate::dsl::registry::FuncSig,
    consts: &[ConstArg],
) -> Result<(), String> {
    use crate::ast::SlotType;
    // Const args appear in positional order, but `sig.params`
    // mixes wire and const slots. Walk both in lockstep, pulling
    // const args from a separate counter.
    let mut const_idx = 0usize;
    for spec in sig.params {
        if matches!(spec.slot_type, SlotType::Wire) {
            continue;
        }
        if let Some(constraint) = &spec.constraint
            && let Some(arg) = consts.get(const_idx)
        {
            constraint.check(arg, spec.name)?;
        }
        const_idx += 1;
    }
    Ok(())
}