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 input-validity contract 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///
38/// The library catalog (`library_catalog.md`, "Parameter resolution
39/// and validation") lists every variant with what it accepts and its
40/// error text; `tests/constraint_catalog.rs` in the `polydat` crate
41/// fails when a variant has no row there.
42#[derive(Debug, Clone, Copy)]
43pub enum ConstConstraint {
44 /// Integer must satisfy `min ≤ v ≤ max`.
45 RangeU64 {
46 /// The least value allowed.
47 min: u64,
48 /// The greatest value allowed.
49 max: u64,
50 },
51 /// Float must satisfy `min ≤ v ≤ max`.
52 RangeF64 {
53 /// The least value allowed.
54 min: f64,
55 /// The greatest value allowed.
56 max: f64,
57 },
58 /// Integer must appear in a closed set (e.g. radix ∈ {2, 8, 10, 16}).
59 AllowedU64(&'static [u64]),
60 /// Integer must be non-zero (divisors, moduli, ranges).
61 NonZeroU64,
62 /// String must have non-empty length after trim.
63 NonEmptyStr,
64 /// Arbitrary string format predicate. Return `Err(msg)` to
65 /// reject the constant; the caller prepends parameter context.
66 /// Use for structured specs like `"v1:w1;v2:w2"` where a fixed
67 /// enum variant can't express the format.
68 StrParser(fn(&str) -> Result<(), String>),
69 /// Float must be finite and strictly positive. Distinct from
70 /// `RangeF64` because the natural upper bound is `+∞` and
71 /// `RangeF64` requires a finite max.
72 PositiveFiniteF64,
73 /// Float must be finite (`!is_nan() && !is_infinite()`).
74 /// Endpoint and offset constants where ±∞/NaN would silently
75 /// produce nonsense outputs downstream.
76 FiniteF64,
77}
78
79impl ConstConstraint {
80 /// Apply this constraint to `arg`. On violation, the returned
81 /// error message is prefixed with `param_name` so the caller
82 /// can surface it directly to the user.
83 pub fn check(&self, arg: &ConstArg, param_name: &str) -> Result<(), String> {
84 match self {
85 ConstConstraint::RangeU64 { min, max } => {
86 let v = arg.as_u64();
87 if v < *min || v > *max {
88 Err(format!("{param_name} must be in [{min}, {max}], got {v}"))
89 } else {
90 Ok(())
91 }
92 }
93 ConstConstraint::RangeF64 { min, max } => {
94 let v = arg.as_f64();
95 if !(*min..=*max).contains(&v) {
96 Err(format!("{param_name} must be in [{min}, {max}], got {v}"))
97 } else {
98 Ok(())
99 }
100 }
101 ConstConstraint::AllowedU64(allowed) => {
102 let v = arg.as_u64();
103 if !allowed.contains(&v) {
104 Err(format!("{param_name} must be one of {allowed:?}, got {v}"))
105 } else {
106 Ok(())
107 }
108 }
109 ConstConstraint::NonZeroU64 => {
110 let v = arg.as_u64();
111 if v == 0 {
112 Err(format!("{param_name} must be non-zero"))
113 } else {
114 Ok(())
115 }
116 }
117 ConstConstraint::NonEmptyStr => {
118 let s = arg.as_str();
119 if s.trim().is_empty() {
120 Err(format!("{param_name} must be non-empty"))
121 } else {
122 Ok(())
123 }
124 }
125 ConstConstraint::StrParser(f) => {
126 let s = arg.as_str();
127 f(s).map_err(|e| format!("{param_name}: {e}"))
128 }
129 ConstConstraint::PositiveFiniteF64 => {
130 let v = arg.as_f64();
131 if !v.is_finite() || v <= 0.0 {
132 Err(format!(
133 "{param_name} must be a positive finite f64, got {v}"
134 ))
135 } else {
136 Ok(())
137 }
138 }
139 ConstConstraint::FiniteF64 => {
140 let v = arg.as_f64();
141 if !v.is_finite() {
142 Err(format!("{param_name} must be a finite f64, got {v}"))
143 } else {
144 Ok(())
145 }
146 }
147 }
148 }
149}
150
151/// Per-module validator the factory calls before `build_node`.
152///
153/// Receives the function name (same key `build_node` dispatches
154/// on) and the resolved constant arguments in positional order.
155/// Returns `Ok(())` if the constants satisfy every declared
156/// constraint, or a structured error on violation.
157///
158/// The error string is prefixed with `bad constant <func>: ` by
159/// the factory, so validators can return terse messages like
160/// `"radix must be one of [2,8,10,16], got 42"`.
161pub type NodeValidator = fn(name: &str, consts: &[ConstArg]) -> Result<(), String>;
162
163/// Convenience: apply a single constraint to an optional positional
164/// argument. Absence is treated as "no value to check" (Ok) — the
165/// `required` flag on `ParamSpec` already handles mandatory-ness.
166pub fn check_opt(
167 constraint: &ConstConstraint,
168 consts: &[ConstArg],
169 index: usize,
170 param_name: &str,
171) -> Result<(), String> {
172 match consts.get(index) {
173 Some(arg) => constraint.check(arg, param_name),
174 None => Ok(()),
175 }
176}