Skip to main content

polydat_core/dsl/
const_constraints.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Assembly-time validation of Polydat node constant arguments.
5//!
6//! Polydat's contract (SRD 15 §"Input Validity Model") keeps the hot
7//! path branch-free by letting node `::new` trust its constants —
8//! no runtime checks. That only holds if the *factory* has already
9//! proven each constant satisfies the node's contract, rejecting
10//! violations with a structured compile error *before* the node
11//! is constructed.
12//!
13//! This module provides the vocabulary for those checks:
14//!
15//! * [`ConstConstraint`] describes a single constraint on one
16//!   constant argument. Apply it with
17//!   [`ConstConstraint::check`].
18//! * [`NodeValidator`] is the per-module function the factory
19//!   calls before `build`. It gets the function name and the
20//!   resolved constant args and returns `Ok(())` or a structured
21//!   error string.
22//!
23//! A module opts in by passing a validator as the third argument
24//! to `register_nodes!`. Modules that don't need validation omit
25//! it and the factory skips the check.
26
27use crate::dsl::factory::ConstArg;
28
29/// A declarative constraint on one constant argument of a node
30/// call.
31///
32/// Attached to a `ParamSpec` via the optional `constraint` field;
33/// the factory walks `FuncSig.params` and enforces every declared
34/// constraint before `build_node` constructs the node. All variants
35/// are `Copy` so `ParamSpec` (and the static `FuncSig` arrays that
36/// embed it) stay `Copy`.
37#[derive(Debug, Clone, Copy)]
38pub enum ConstConstraint {
39    /// Integer must satisfy `min ≤ v ≤ max`.
40    RangeU64 {
41        /// The least value allowed.
42        min: u64,
43        /// The greatest value allowed.
44        max: u64,
45    },
46    /// Float must satisfy `min ≤ v ≤ max`.
47    RangeF64 {
48        /// The least value allowed.
49        min: f64,
50        /// The greatest value allowed.
51        max: f64,
52    },
53    /// Integer must appear in a closed set (e.g. radix ∈ {2, 8, 10, 16}).
54    AllowedU64(&'static [u64]),
55    /// Integer must be non-zero (divisors, moduli, ranges).
56    NonZeroU64,
57    /// String must have non-empty length after trim.
58    NonEmptyStr,
59    /// Arbitrary string format predicate. Return `Err(msg)` to
60    /// reject the constant; the caller prepends parameter context.
61    /// Use for structured specs like `"v1:w1;v2:w2"` where a fixed
62    /// enum variant can't express the format.
63    StrParser(fn(&str) -> Result<(), String>),
64    /// Float must be finite and strictly positive. Distinct from
65    /// `RangeF64` because the natural upper bound is `+∞` and
66    /// `RangeF64` requires a finite max.
67    PositiveFiniteF64,
68    /// Float must be finite (`!is_nan() && !is_infinite()`).
69    /// Endpoint and offset constants where ±∞/NaN would silently
70    /// produce nonsense outputs downstream.
71    FiniteF64,
72}
73
74impl ConstConstraint {
75    /// Apply this constraint to `arg`. On violation, the returned
76    /// error message is prefixed with `param_name` so the caller
77    /// can surface it directly to the user.
78    pub fn check(&self, arg: &ConstArg, param_name: &str) -> Result<(), String> {
79        match self {
80            ConstConstraint::RangeU64 { min, max } => {
81                let v = arg.as_u64();
82                if v < *min || v > *max {
83                    Err(format!("{param_name} must be in [{min}, {max}], got {v}"))
84                } else {
85                    Ok(())
86                }
87            }
88            ConstConstraint::RangeF64 { min, max } => {
89                let v = arg.as_f64();
90                if !(*min..=*max).contains(&v) {
91                    Err(format!("{param_name} must be in [{min}, {max}], got {v}"))
92                } else {
93                    Ok(())
94                }
95            }
96            ConstConstraint::AllowedU64(allowed) => {
97                let v = arg.as_u64();
98                if !allowed.contains(&v) {
99                    Err(format!("{param_name} must be one of {allowed:?}, got {v}"))
100                } else {
101                    Ok(())
102                }
103            }
104            ConstConstraint::NonZeroU64 => {
105                let v = arg.as_u64();
106                if v == 0 {
107                    Err(format!("{param_name} must be non-zero"))
108                } else {
109                    Ok(())
110                }
111            }
112            ConstConstraint::NonEmptyStr => {
113                let s = arg.as_str();
114                if s.trim().is_empty() {
115                    Err(format!("{param_name} must be non-empty"))
116                } else {
117                    Ok(())
118                }
119            }
120            ConstConstraint::StrParser(f) => {
121                let s = arg.as_str();
122                f(s).map_err(|e| format!("{param_name}: {e}"))
123            }
124            ConstConstraint::PositiveFiniteF64 => {
125                let v = arg.as_f64();
126                if !v.is_finite() || v <= 0.0 {
127                    Err(format!(
128                        "{param_name} must be a positive finite f64, got {v}"
129                    ))
130                } else {
131                    Ok(())
132                }
133            }
134            ConstConstraint::FiniteF64 => {
135                let v = arg.as_f64();
136                if !v.is_finite() {
137                    Err(format!("{param_name} must be a finite f64, got {v}"))
138                } else {
139                    Ok(())
140                }
141            }
142        }
143    }
144}
145
146/// Per-module validator the factory calls before `build_node`.
147///
148/// Receives the function name (same key `build_node` dispatches
149/// on) and the resolved constant arguments in positional order.
150/// Returns `Ok(())` if the constants satisfy every declared
151/// constraint, or a structured error on violation.
152///
153/// The error string is prefixed with `bad constant <func>: ` by
154/// the factory, so validators can return terse messages like
155/// `"radix must be one of [2,8,10,16], got 42"`.
156pub type NodeValidator = fn(name: &str, consts: &[ConstArg]) -> Result<(), String>;
157
158/// Convenience: apply a single constraint to an optional positional
159/// argument. Absence is treated as "no value to check" (Ok) — the
160/// `required` flag on `ParamSpec` already handles mandatory-ness.
161pub fn check_opt(
162    constraint: &ConstConstraint,
163    consts: &[ConstArg],
164    index: usize,
165    param_name: &str,
166) -> Result<(), String> {
167    match consts.get(index) {
168        Some(arg) => constraint.check(arg, param_name),
169        None => Ok(()),
170    }
171}