Skip to main content

polydat_nodes/
param_helpers.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Parameter resolution and validation helpers (library_catalog.md,
5//! "Parameter resolution and validation").
6//!
7//! These nodes are pass-throughs with assertions on the value they
8//! carry. They let workloads say "this parameter must be defined",
9//! "this number must be in range", "this string must match a
10//! pattern" — with the assertion fired at the earliest point the
11//! value is known. For compile-time-constant inputs, constant
12//! folding collapses the assertion into a hard compile error. For
13//! init-time-resolved workload params, the assertion fires on the
14//! first evaluation (effectively init). For live-read inputs, the
15//! assertion fires per cycle.
16//!
17//! Failure is reported via panic with a descriptive message. Panics
18//! inside `eval` surface as workload startup errors for init-time
19//! values and as cycle-time aborts for live-read inputs; both are
20//! the intended consequence of a violated precondition.
21
22use regex::Regex;
23
24// =========================================================================
25// required(input) — assert non-None, pass through
26// =========================================================================
27
28/// Assert that an input is defined (i.e. not `Value::None`).
29///
30/// Signature: `required(input: u64) -> u64`. The `Option<u64>`
31/// wire shape opts the node into kernel-Rule-1 bypass (the macro
32/// auto-emits `accepts_none_inputs() -> true` when any arg is
33/// `Option<_>`); the body's `unwrap_or_else` is what actually
34/// fires the diagnostic.
35#[polydat::polydat_node(category = Arithmetic)]
36fn required(
37    input: Option<u64>,
38    #[poly_default("value")] name: polydat::derive_support::Const<&str>,
39) -> u64 {
40    input.unwrap_or_else(|| panic!("required({}): value was not defined", name.0))
41}
42
43// =========================================================================
44// this_or(primary, default) — first if defined else second
45// =========================================================================
46
47/// Return `primary` if it is defined, otherwise `default`.
48///
49/// Signature: `this_or(primary: Option<u64>, default: u64) -> u64`.
50/// The typed-coalesce equivalent of `default_or` — `None` on
51/// `primary` triggers the fallback. `Option<u64>` opts into
52/// None-tolerance via the macro's auto-emitted
53/// `accepts_none_inputs`.
54#[polydat::polydat_node(category = Arithmetic)]
55fn this_or(primary: Option<u64>, default: u64) -> u64 {
56    primary.unwrap_or(default)
57}
58
59// =========================================================================
60// is_positive(input) — assert > 0, pass through
61// =========================================================================
62
63/// Assert that a u64 value is strictly positive (> 0).
64///
65/// Signature: `is_positive(input: u64) -> u64`
66#[polydat::polydat_node(category = Arithmetic)]
67fn is_positive(
68    input: u64,
69    #[poly_default("value")] name: polydat::derive_support::Const<&str>,
70) -> u64 {
71    if input == 0 {
72        panic!("is_positive({}): value must be > 0, got 0", name.0);
73    }
74    input
75}
76
77// =========================================================================
78// in_range(input, lo, hi) — assert lo ≤ input ≤ hi, pass through
79// =========================================================================
80
81/// Assert that a u64 value is in the inclusive range `[lo, hi]`.
82///
83/// Signature: `in_range(input: u64, lo: u64, hi: u64) -> u64`
84#[polydat::polydat_node(category = Arithmetic, validate = in_range_validate)]
85fn in_range(
86    input: u64,
87    #[poly_default(0u64)] lo: polydat::derive_support::Const<u64>,
88    #[poly_default(u64::MAX)] hi: polydat::derive_support::Const<u64>,
89) -> u64 {
90    if input < *lo || input > *hi {
91        panic!("in_range: value {input} outside [{}, {}]", *lo, *hi);
92    }
93    input
94}
95
96/// `in_range`'s relation between its two bounds. Node-level because
97/// no per-parameter constraint can compare two parameters.
98fn in_range_validate(
99    _name: &str,
100    consts: &[polydat::dsl::factory::ConstArg],
101) -> Result<(), String> {
102    let lo = consts.first().map(|c| c.as_u64()).unwrap_or(0);
103    let hi = consts.get(1).map(|c| c.as_u64()).unwrap_or(u64::MAX);
104    if lo > hi {
105        return Err(format!("lo ({lo}) must be <= hi ({hi})"));
106    }
107    Ok(())
108}
109
110// =========================================================================
111// is_one_of(input, ...allowed) — assert input ∈ {allowed}, pass through
112// =========================================================================
113
114/// Assert that a u64 value is one of an enumerated allow-list.
115/// Authored with the `Const<Vec<C>>` combinator and lowered through its slot kit (called from native code);
116/// `classify_node` returns Fallback for it because a
117/// `Const<Vec<u64>>` node publishes no `jit_constants`, so the
118/// `JitOp::IsOneOfCheck` arm never fires.
119#[polydat::polydat_node(category = Arithmetic, validate = is_one_of_validate)]
120fn is_one_of(input: u64, allowed: polydat::derive_support::Const<&[u64]>) -> u64 {
121    if !allowed.contains(&input) {
122        panic!(
123            "is_one_of: value {input} not in allowed set {:?}",
124            allowed.0
125        );
126    }
127    input
128}
129
130/// `is_one_of`'s allow-list must name at least one value. A list's
131/// arity is a property of the whole argument rather than of any one
132/// element, so it is stated at the node.
133fn is_one_of_validate(
134    _name: &str,
135    consts: &[polydat::dsl::factory::ConstArg],
136) -> Result<(), String> {
137    if consts.is_empty() {
138        return Err("at least one allowed value required".into());
139    }
140    Ok(())
141}
142
143// =========================================================================
144// matches(input, pattern) — assert regex match, pass through
145// =========================================================================
146
147/// Build a Regex from a pattern, panicking on invalid input.
148fn compile_matches_regex(pattern: &str) -> Regex {
149    Regex::new(pattern).unwrap_or_else(|e| panic!("matches: invalid regex {pattern:?}: {e}"))
150}
151
152/// Assert that a string value matches a regex pattern.
153#[polydat::polydat_node(category = Arithmetic)]
154fn matches(
155    input: &str,
156    pattern: polydat::derive_support::Const<&str>,
157    #[poly_const(compile_matches_regex, from = pattern)] re: &Regex,
158) -> String {
159    if !re.is_match(input) {
160        panic!(
161            "matches: value {input:?} does not match pattern {:?}",
162            pattern.0
163        );
164    }
165    input.to_string()
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use polydat::ast::{PolydatNode, Value};
172
173    #[test]
174    fn required_passes_defined_value() {
175        let n = Required::new("x".to_string());
176        let mut out = [Value::None];
177        n.eval(&[Value::U64(42)], &mut out);
178        assert_eq!(out[0].as_u64(), 42);
179    }
180
181    #[test]
182    #[should_panic(expected = "required(x): value was not defined")]
183    fn required_panics_on_none() {
184        let n = Required::new("x".to_string());
185        let mut out = [Value::None];
186        n.eval(&[Value::None], &mut out);
187    }
188
189    #[test]
190    fn this_or_prefers_primary_when_defined() {
191        let n = ThisOr::new();
192        let mut out = [Value::None];
193        n.eval(&[Value::U64(7), Value::U64(99)], &mut out);
194        assert_eq!(out[0].as_u64(), 7);
195    }
196
197    #[test]
198    fn this_or_falls_back_to_default_on_none() {
199        let n = ThisOr::new();
200        let mut out = [Value::None];
201        n.eval(&[Value::None, Value::U64(99)], &mut out);
202        assert_eq!(out[0].as_u64(), 99);
203    }
204
205    #[test]
206    fn is_positive_passes_positive() {
207        let n = IsPositive::new("rate".to_string());
208        let mut out = [Value::None];
209        n.eval(&[Value::U64(1)], &mut out);
210        assert_eq!(out[0].as_u64(), 1);
211    }
212
213    #[test]
214    #[should_panic(expected = "is_positive(rate)")]
215    fn is_positive_panics_on_zero() {
216        let n = IsPositive::new("rate".to_string());
217        let mut out = [Value::None];
218        n.eval(&[Value::U64(0)], &mut out);
219    }
220
221    #[test]
222    fn in_range_passes_interior() {
223        let n = InRange::new(10, 100);
224        let mut out = [Value::None];
225        n.eval(&[Value::U64(50)], &mut out);
226        assert_eq!(out[0].as_u64(), 50);
227        n.eval(&[Value::U64(10)], &mut out);
228        assert_eq!(out[0].as_u64(), 10);
229        n.eval(&[Value::U64(100)], &mut out);
230        assert_eq!(out[0].as_u64(), 100);
231    }
232
233    #[test]
234    #[should_panic(expected = "outside [10, 100]")]
235    fn in_range_panics_below() {
236        let n = InRange::new(10, 100);
237        let mut out = [Value::None];
238        n.eval(&[Value::U64(5)], &mut out);
239    }
240
241    #[test]
242    #[should_panic(expected = "outside [10, 100]")]
243    fn in_range_panics_above() {
244        let n = InRange::new(10, 100);
245        let mut out = [Value::None];
246        n.eval(&[Value::U64(101)], &mut out);
247    }
248
249    #[test]
250    fn is_one_of_passes_allowed() {
251        let n = IsOneOf::new(vec![1, 2, 3, 5, 8]);
252        let mut out = [Value::None];
253        n.eval(&[Value::U64(5)], &mut out);
254        assert_eq!(out[0].as_u64(), 5);
255    }
256
257    #[test]
258    #[should_panic(expected = "not in allowed set")]
259    fn is_one_of_panics_on_disallowed() {
260        let n = IsOneOf::new(vec![1, 2, 3]);
261        let mut out = [Value::None];
262        n.eval(&[Value::U64(4)], &mut out);
263    }
264
265    #[test]
266    fn matches_passes_matching_string() {
267        let n = Matches::new(r"^\w+@\w+\.\w+$".to_string());
268        let mut out = [Value::None];
269        n.eval(&[Value::Str("jshook@example.com".into())], &mut out);
270        assert_eq!(out[0].as_str(), "jshook@example.com");
271    }
272
273    #[test]
274    #[should_panic(expected = "does not match pattern")]
275    fn matches_panics_on_mismatch() {
276        let n = Matches::new(r"^\d+$".to_string());
277        let mut out = [Value::None];
278        n.eval(&[Value::Str("abc".into())], &mut out);
279    }
280}