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}