Skip to main content

polydat_nodes/
lerp.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Linear interpolation and range mapping nodes.
5
6use polydat::compile::fusion::{DecomposedGraph, DecomposedWire, FusedNode};
7
8/// Linear interpolation with fixed endpoints.
9///
10/// Signature: `lerp(t: f64, a: f64, b: f64) -> (f64)`
11/// Result: `a + t * (b - a)` where a, b are init-time params.
12///
13/// When t=0 the output is a, t=1 gives b, t=0.5 gives the midpoint.
14/// Use after `unit_interval` to map a normalized `[0,1)` value into an
15/// arbitrary continuous range. Example: `lerp(unit_interval(h), -180.0,
16/// 180.0)` produces a random longitude. Accepts t outside `[0,1]` for
17/// extrapolation.
18///
19/// JIT level: P3 (macro-emitted `compiled_u64` + `jit_constants`
20/// `[a.to_bits(), b.to_bits()]` matching the JIT codegen
21/// `JitOp::LerpConst(a_bits, b_bits)` layout).
22#[polydat::polydat_node(category = Interpolation)]
23fn lerp(
24    t: f64,
25    #[poly_default(0.0f64)] a: polydat::derive_support::Const<f64>,
26    #[poly_default(1.0f64)] b: polydat::derive_support::Const<f64>,
27) -> f64 {
28    *a + t * (*b - *a)
29}
30
31/// Map a u64 linearly to an f64 range.
32///
33/// Signature: `scale_range(input: u64, min: f64, max: f64) -> (f64)`
34/// Maps [0, u64::MAX] to [min, max].
35///
36/// Convenience node that fuses `unit_interval` + `lerp` into a single
37/// step. Use directly after `hash` when you need a uniform f64 in a
38/// custom range without wiring two separate nodes. Example:
39/// `scale_range(hash(cycle), 0.0, 1000.0)` gives a uniform float in
40/// [0, 1000].
41///
42/// JIT level: P3. `jit_constants` is overridden to emit the
43/// `(min, range)` pair that `JitOp::ScaleRangeConst` expects;
44/// the macro-derived default would emit `(min, max)` which the
45/// JIT codegen would interpret incorrectly.
46#[polydat::polydat_node(
47    category = Interpolation,
48    jit_constants = scale_range_jit_constants,
49)]
50fn scale_range(
51    input: u64,
52    #[poly_default(0.0f64)] min: polydat::derive_support::Const<f64>,
53    #[poly_default(1.0f64)] max: polydat::derive_support::Const<f64>,
54) -> f64 {
55    let t = input as f64 / u64::MAX as f64;
56    *min + t * (*max - *min)
57}
58
59/// JIT-constants override for `scale_range`: emit the
60/// `(min, range)` layout that `JitOp::ScaleRangeConst`
61/// (polydat-core/src/compile/jit/codegen.rs) consumes.
62fn scale_range_jit_constants(node: &ScaleRange) -> Vec<u64> {
63    vec![node.min.to_bits(), (node.max - node.min).to_bits()]
64}
65
66impl FusedNode for ScaleRange {
67    /// `scale_range(x, lo, hi)` decomposes to `lerp(unit_interval(x), lo, hi)`.
68    fn decomposed(&self) -> DecomposedGraph {
69        use crate::sampling::icd::UnitInterval;
70        let mut g = DecomposedGraph::new(1);
71        let ui = g.add_node(
72            Box::new(UnitInterval::new()),
73            vec![DecomposedWire::Input(0)],
74        );
75        let lerp = g.add_node(
76            Box::new(Lerp::new(self.min, self.max)),
77            vec![DecomposedWire::Node(ui, 0)],
78        );
79        g.set_outputs(vec![DecomposedWire::Node(lerp, 0)]);
80        g
81    }
82}
83
84/// Inverse linear interpolation: map [a, b] to [0, 1].
85///
86/// Signature: `inv_lerp(input: f64, a: f64, b: f64) -> (f64)`
87/// Result: `(input - a) / (b - a)`, clamped to `[0, 1]`.
88///
89/// The reverse of `lerp`: normalizes an arbitrary continuous range
90/// back to `[0,1]`. Use as the first half of a `remap`, or to feed a
91/// domain-specific value into a node that expects unit input. Example:
92/// `inv_lerp(temperature, 32.0, 212.0)` normalizes Fahrenheit to
93/// `[0,1]`. Output is clamped, so out-of-range inputs saturate.
94///
95/// The per-call `1.0 / (b - a)` divide is computed inline; it is
96/// cheaper than a multi-source setup mechanism that no other node
97/// would need.
98#[polydat::polydat_node(category = Interpolation)]
99fn inv_lerp(
100    input: f64,
101    #[poly_default(0.0f64)] a: polydat::derive_support::Const<f64>,
102    #[poly_default(1.0f64)] b: polydat::derive_support::Const<f64>,
103) -> f64 {
104    let inv_range = 1.0 / (*b - *a);
105    let t = (input - *a) * inv_range;
106    t.clamp(0.0, 1.0)
107}
108
109/// Remap from one range to another.
110///
111/// Signature: `remap(input: f64, in_min: f64, in_max: f64, out_min: f64, out_max: f64) -> (f64)`
112/// Maps [in_min, in_max] to [out_min, out_max] linearly.
113///
114/// Combines `inv_lerp` + `lerp` in one node. Use for unit conversions
115/// or rescaling distribution outputs. Example:
116/// `remap(value, 32.0, 212.0, 0.0, 100.0)` converts Fahrenheit to
117/// Celsius. Unlike `inv_lerp`, the output is not clamped, so
118/// extrapolation is possible.
119///
120/// JIT level: P3 (named JitOp; the constants are the four f64 bit
121/// patterns). The `1.0 / (in_max - in_min)` divide is computed inline
122/// per call.
123#[polydat::polydat_node(category = Interpolation)]
124fn remap(
125    input: f64,
126    #[poly_default(0.0f64)] in_min: polydat::derive_support::Const<f64>,
127    #[poly_default(1.0f64)] in_max: polydat::derive_support::Const<f64>,
128    #[poly_default(0.0f64)] out_min: polydat::derive_support::Const<f64>,
129    #[poly_default(1.0f64)] out_max: polydat::derive_support::Const<f64>,
130) -> f64 {
131    let t = (input - *in_min) / (*in_max - *in_min);
132    *out_min + t * (*out_max - *out_min)
133}
134
135/// Quantize an f64 to the nearest multiple of a step size.
136///
137/// Signature: `quantize(input: f64, step: f64) -> (f64)`
138/// Result: `round(input / step) * step`
139///
140/// Snaps continuous values to a discrete grid. Use for rounding
141/// prices to the nearest cent (`quantize(price, 0.01)`), snapping
142/// coordinates to a tile grid (`quantize(x, 16.0)`), or binning
143/// timestamps to fixed intervals. Unlike `discretize`, the output
144/// remains f64 at the grid point, not a bucket index.
145///
146/// JIT level: P3 (macro-emitted; consts = `[step.to_bits()]`).
147///
148/// `step` is not validated at construction (the macro does not yet
149/// support const-arg `ConstConstraint` metadata). As with `div` /
150/// `mod` in arithmetic.rs, a non-positive `step` propagates a
151/// NaN/inf through the body, surfacing at cycle time.
152#[polydat::polydat_node(category = Interpolation)]
153fn quantize(input: f64, #[poly_default(1.0f64)] step: polydat::derive_support::Const<f64>) -> f64 {
154    (input / *step).round() * *step
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use polydat::ast::{PolydatNode, Value};
161
162    #[test]
163    fn lerp_endpoints() {
164        let node = Lerp::new(10.0, 20.0);
165        let mut out = [Value::None];
166        node.eval(&[Value::F64(0.0)], &mut out);
167        assert_eq!(out[0].as_f64(), 10.0);
168        node.eval(&[Value::F64(1.0)], &mut out);
169        assert_eq!(out[0].as_f64(), 20.0);
170    }
171
172    #[test]
173    fn lerp_midpoint() {
174        let node = Lerp::new(0.0, 100.0);
175        let mut out = [Value::None];
176        node.eval(&[Value::F64(0.5)], &mut out);
177        assert_eq!(out[0].as_f64(), 50.0);
178    }
179
180    #[test]
181    fn scale_range_bounds() {
182        let node = ScaleRange::new(10.0, 20.0);
183        let mut out = [Value::None];
184        node.eval(&[Value::U64(0)], &mut out);
185        assert!((out[0].as_f64() - 10.0).abs() < 0.001);
186        node.eval(&[Value::U64(u64::MAX)], &mut out);
187        assert!((out[0].as_f64() - 20.0).abs() < 0.001);
188    }
189
190    #[test]
191    fn scale_range_jit_constants_layout() {
192        // JIT codegen consumes (min, range); the override
193        // must emit that pair regardless of struct field order.
194        let node = ScaleRange::new(10.0, 25.0);
195        let consts = node.jit_constants();
196        assert_eq!(consts.len(), 2);
197        assert_eq!(f64::from_bits(consts[0]), 10.0);
198        assert_eq!(f64::from_bits(consts[1]), 15.0); // range = max - min
199    }
200
201    #[test]
202    fn inv_lerp_basic() {
203        let node = InvLerp::new(10.0, 20.0);
204        let mut out = [Value::None];
205        node.eval(&[Value::F64(10.0)], &mut out);
206        assert!((out[0].as_f64() - 0.0).abs() < 0.001);
207        node.eval(&[Value::F64(15.0)], &mut out);
208        assert!((out[0].as_f64() - 0.5).abs() < 0.001);
209        node.eval(&[Value::F64(20.0)], &mut out);
210        assert!((out[0].as_f64() - 1.0).abs() < 0.001);
211    }
212
213    #[test]
214    fn inv_lerp_clamps() {
215        let node = InvLerp::new(0.0, 100.0);
216        let mut out = [Value::None];
217        node.eval(&[Value::F64(-50.0)], &mut out);
218        assert_eq!(out[0].as_f64(), 0.0);
219        node.eval(&[Value::F64(200.0)], &mut out);
220        assert_eq!(out[0].as_f64(), 1.0);
221    }
222
223    #[test]
224    fn remap_basic() {
225        let node = Remap::new(0.0, 100.0, 0.0, 1.0);
226        let mut out = [Value::None];
227        node.eval(&[Value::F64(50.0)], &mut out);
228        assert!((out[0].as_f64() - 0.5).abs() < 0.001);
229    }
230
231    #[test]
232    fn remap_different_ranges() {
233        // Fahrenheit to Celsius: [32, 212] → [0, 100]
234        let node = Remap::new(32.0, 212.0, 0.0, 100.0);
235        let mut out = [Value::None];
236        node.eval(&[Value::F64(32.0)], &mut out);
237        assert!((out[0].as_f64() - 0.0).abs() < 0.001);
238        node.eval(&[Value::F64(212.0)], &mut out);
239        assert!((out[0].as_f64() - 100.0).abs() < 0.001);
240        node.eval(&[Value::F64(72.0)], &mut out);
241        assert!((out[0].as_f64() - 22.22).abs() < 0.1);
242    }
243
244    #[test]
245    fn quantize_basic() {
246        let node = Quantize::new(10.0);
247        let mut out = [Value::None];
248        node.eval(&[Value::F64(13.0)], &mut out);
249        assert_eq!(out[0].as_f64(), 10.0);
250        node.eval(&[Value::F64(17.0)], &mut out);
251        assert_eq!(out[0].as_f64(), 20.0);
252        node.eval(&[Value::F64(15.0)], &mut out);
253        assert_eq!(out[0].as_f64(), 20.0); // round-half-up
254    }
255
256    #[test]
257    fn quantize_small_step() {
258        let node = Quantize::new(0.25);
259        let mut out = [Value::None];
260        node.eval(&[Value::F64(1.3)], &mut out);
261        assert!((out[0].as_f64() - 1.25).abs() < 0.001);
262    }
263}
264
265// ── Fusion rule ────────────────────────────────────────────────
266
267use polydat::compile::fusion::{FusionPattern, FusionRule, FusionRuleRegistration};
268
269/// `lerp(unit_interval(x), lo, hi)` → `scale_range(x, lo, hi)`: the
270/// intermediate unit interval eliminated. Registered after the hash
271/// rules, so a hash upstream takes `hash_interval` first.
272fn unit_lerp_to_scale_range() -> FusionRule {
273    FusionRule {
274        name: "unit_lerp_to_scale_range",
275        pattern: FusionPattern::node(
276            "lerp",
277            vec![FusionPattern::node(
278                "unit_interval",
279                vec![FusionPattern::any("x")],
280                "ui_node",
281            )],
282            "lerp_node",
283        ),
284        replacement: |m| {
285            let consts = m.const_vec("lerp_node");
286            let lo = f64::from_bits(consts[0]);
287            let hi = f64::from_bits(consts[1]);
288            Box::new(ScaleRange::new(lo, hi))
289        },
290        input_bindings: &["x"],
291    }
292}
293
294polydat::inventory::submit! {
295    FusionRuleRegistration { priority: 30, build: unit_lerp_to_scale_range }
296}