Skip to main content

polydat_nodes/sampling/
lut.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! General-purpose interpolating lookup table (LUT).
5//!
6//! A `LutF64` pre-computes a function `f: [0,1] → f64` at evenly spaced
7//! points and provides O(1) linear interpolation at query time. This is
8//! the building block that distribution ICD sampling, arbitrary function
9//! approximation, and any precomputed f64→f64 mapping can use.
10//!
11//! The LUT is built at assembly time from any `Fn(f64) -> f64`. At
12//! runtime, querying is a single array index + lerp — no branching on
13//! distribution type, no function pointer call per sample.
14
15use polydat::ast::{CompiledU64Op, NodeMeta, PolydatNode, Port, PortType, Slot, Value};
16
17/// A pre-computed interpolating lookup table mapping [0, 1] → f64.
18///
19/// Built at assembly time. Immutable and thread-safe after construction.
20pub struct LutF64 {
21    /// Precomputed values at evenly spaced quantiles.
22    /// Length = resolution + 1 (includes both endpoints).
23    lut: Vec<f64>,
24}
25
26impl LutF64 {
27    /// Build from an arbitrary function over [0, 1].
28    ///
29    /// `f(p)` is evaluated at `resolution + 1` evenly spaced points
30    /// from 0.0 to 1.0. Non-finite results are replaced with the
31    /// nearest finite neighbor.
32    pub fn from_fn(f: impl Fn(f64) -> f64, resolution: usize) -> Self {
33        assert!(resolution > 0, "resolution must be positive");
34        let mut lut = Vec::with_capacity(resolution + 1);
35        for i in 0..=resolution {
36            let p = i as f64 / resolution as f64;
37            lut.push(f(p));
38        }
39        // Replace non-finite values by scanning forward then backward
40        Self::sanitize(&mut lut);
41        Self { lut }
42    }
43
44    /// Build from a pre-computed slice of values.
45    pub fn from_values(values: &[f64]) -> Self {
46        assert!(values.len() >= 2, "LUT must have at least 2 entries");
47        let mut lut = values.to_vec();
48        Self::sanitize(&mut lut);
49        Self { lut }
50    }
51
52    /// Replace non-finite entries with nearest finite neighbor.
53    fn sanitize(lut: &mut [f64]) {
54        // Forward pass: replace -inf/nan at start with first finite value
55        let mut last_finite = 0.0;
56        let mut found_first = false;
57        for v in lut.iter_mut() {
58            if v.is_finite() {
59                last_finite = *v;
60                found_first = true;
61            } else if found_first {
62                *v = last_finite;
63            }
64        }
65        // Backward pass: replace -inf/nan at start with first finite value from end
66        let mut last_finite = 0.0;
67        for v in lut.iter_mut().rev() {
68            if v.is_finite() {
69                last_finite = *v;
70            } else {
71                *v = last_finite;
72            }
73        }
74    }
75
76    /// Query the LUT with linear interpolation.
77    ///
78    /// `u` should be in [0.0, 1.0]. Values outside are clamped.
79    #[inline]
80    pub fn sample(&self, u: f64) -> f64 {
81        let u = u.clamp(0.0, 1.0);
82        let n = (self.lut.len() - 1) as f64;
83        let pos = u * n;
84        let idx = (pos as usize).min(self.lut.len() - 2);
85        let frac = pos - idx as f64;
86        self.lut[idx] * (1.0 - frac) + self.lut[idx + 1] * frac
87    }
88
89    /// Number of precomputed points (resolution + 1).
90    pub fn len(&self) -> usize {
91        self.lut.len()
92    }
93
94    /// Whether the LUT has no precomputed points.
95    pub fn is_empty(&self) -> bool {
96        self.lut.is_empty()
97    }
98
99    /// Raw pointer to the LUT data (for JIT constant baking).
100    pub fn as_ptr(&self) -> *const f64 {
101        self.lut.as_ptr()
102    }
103
104    /// The resolution (number of intervals).
105    pub fn resolution(&self) -> usize {
106        self.lut.len() - 1
107    }
108}
109
110// -----------------------------------------------------------------
111// Polydat node: LutSample (f64 → f64)
112// -----------------------------------------------------------------
113
114/// Polydat node that performs interpolating lookup in a precomputed table.
115///
116/// Signature: `lut_sample(input: f64) -> (f64)`
117///
118/// Input is a value in [0, 1]. Output is the interpolated table value.
119/// This is a general-purpose node -- it doesn't know or care whether the
120/// table holds an inverse CDF, a transfer function, or anything else.
121///
122/// Use as the low-level building block for any precomputed f64-to-f64
123/// mapping. Distribution sampling (via `IcdSample`), custom transfer
124/// curves, and empirical data all route through this node at runtime.
125/// The lookup is O(1): a single array index plus one linear
126/// interpolation, with no branching on distribution type.
127///
128/// JIT level: P3 (compiled_u64 with jit_constants exposing the LUT
129/// pointer and length for potential native code generation).
130pub struct LutSample {
131    meta: NodeMeta,
132    table: LutF64,
133}
134
135impl LutSample {
136    /// Create from a pre-built LUT.
137    pub fn new(table: LutF64) -> Self {
138        Self {
139            meta: NodeMeta {
140                name: "lut_sample".into(),
141                outs: vec![Port::new("output", PortType::F64)],
142                ins: vec![Slot::Wire(Port::new("input", PortType::F64))],
143            },
144            table,
145        }
146    }
147}
148
149impl PolydatNode for LutSample {
150    fn meta(&self) -> &NodeMeta {
151        &self.meta
152    }
153
154    fn eval(&self, inputs: &[Value], outputs: &mut [Value]) {
155        outputs[0] = Value::F64(self.table.sample(inputs[0].as_f64()));
156    }
157
158    fn compiled_u64(&self) -> Option<CompiledU64Op> {
159        // Capture the pointer as usize to satisfy Send+Sync.
160        // Safety: the LUT is immutable after construction and outlives
161        // the closure (both are owned by the same PolydatNode).
162        let lut_addr = self.table.lut.as_ptr() as usize;
163        let lut_len = self.table.lut.len();
164        Some(Box::new(move |inputs, outputs| {
165            let u = f64::from_bits(inputs[0]).clamp(0.0, 1.0);
166            let n = (lut_len - 1) as f64;
167            let pos = u * n;
168            let idx = (pos as usize).min(lut_len - 2);
169            let frac = pos - idx as f64;
170            let result = unsafe {
171                let ptr = lut_addr as *const f64;
172                let a = *ptr.add(idx);
173                let b = *ptr.add(idx + 1);
174                a * (1.0 - frac) + b * frac
175            };
176            outputs[0] = result.to_bits();
177        }))
178    }
179
180    fn jit_constants(&self) -> Vec<u64> {
181        vec![self.table.lut.as_ptr() as u64, self.table.lut.len() as u64]
182    }
183}
184
185// `EmpiricalSample` parses the spec at construction time into a
186// `LutF64` (`#[poly_const]` setup); eval samples from the cached
187// table.
188
189impl polydat::derive_support::PolydatSetup for LutF64 {}
190
191/// Parse a free-form spec ("1.0 2.5 7" or "1.0,2.5,7") into a
192/// sorted `LutF64`. Single-call setup invoked by the macro.
193fn parse_empirical_lut(spec: &str) -> LutF64 {
194    let mut values: Vec<f64> = spec
195        .split([' ', ',', ';'])
196        .filter(|s| !s.trim().is_empty())
197        .map(|s| {
198            s.trim()
199                .parse::<f64>()
200                .expect("invalid empirical data point")
201        })
202        .collect();
203    assert!(
204        values.len() >= 2,
205        "empirical distribution needs at least 2 data points"
206    );
207    values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
208    LutF64::from_values(&values)
209}
210
211/// Sample from an empirical distribution defined by a list of
212/// data points. The data points become the LUT entries
213fn dist_empirical_jit_constants(node: &DistEmpirical) -> Vec<u64> {
214    vec![node.table.as_ptr() as u64, node.table.len() as u64]
215}
216
217/// Sample from an empirical distribution defined by a list of
218/// data points. The data points become the LUT entries
219/// (sorted); linear interpolation gives continuous sampling.
220/// Input should be in `[0, 1]` (from `unit_interval`).
221#[polydat::polydat_node(category = Probability, jit_constants = dist_empirical_jit_constants)]
222fn dist_empirical(
223    input: f64,
224    spec: polydat::derive_support::Const<&str>,
225    #[poly_const(parse_empirical_lut, from = spec)] table: &LutF64,
226) -> f64 {
227    table.sample(input)
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn lut_identity() {
236        let table = LutF64::from_fn(|p| p, 100);
237        assert!((table.sample(0.0) - 0.0).abs() < 1e-10);
238        assert!((table.sample(0.5) - 0.5).abs() < 0.01);
239        assert!((table.sample(1.0) - 1.0).abs() < 1e-10);
240    }
241
242    #[test]
243    fn lut_quadratic() {
244        let table = LutF64::from_fn(|p| p * p, 1000);
245        assert!((table.sample(0.5) - 0.25).abs() < 0.001);
246        assert!((table.sample(0.0) - 0.0).abs() < 1e-10);
247        assert!((table.sample(1.0) - 1.0).abs() < 0.001);
248    }
249
250    #[test]
251    fn lut_clamps_input() {
252        let table = LutF64::from_fn(|p| p * 10.0, 100);
253        // Negative input clamps to 0
254        assert!((table.sample(-0.5) - 0.0).abs() < 1e-10);
255        // Input > 1 clamps to 1
256        assert!((table.sample(1.5) - 10.0).abs() < 1e-10);
257    }
258
259    #[test]
260    fn lut_sanitizes_infinities() {
261        let table = LutF64::from_fn(
262            |p| {
263                if !(0.01..=0.99).contains(&p) {
264                    f64::INFINITY
265                } else {
266                    p
267                }
268            },
269            100,
270        );
271        // Edges should be replaced with nearest finite values
272        assert!(table.sample(0.0).is_finite());
273        assert!(table.sample(1.0).is_finite());
274    }
275
276    #[test]
277    fn lut_from_values() {
278        let table = LutF64::from_values(&[0.0, 5.0, 10.0]);
279        assert!((table.sample(0.0) - 0.0).abs() < 1e-10);
280        assert!((table.sample(0.5) - 5.0).abs() < 1e-10);
281        assert!((table.sample(1.0) - 10.0).abs() < 1e-10);
282        // Interpolation at 0.25 should give 2.5
283        assert!((table.sample(0.25) - 2.5).abs() < 1e-10);
284    }
285
286    #[test]
287    fn lut_node_eval() {
288        let table = LutF64::from_fn(|p| p * 100.0, 1000);
289        let node = LutSample::new(table);
290        let mut out = [Value::None];
291        node.eval(&[Value::F64(0.5)], &mut out);
292        assert!((out[0].as_f64() - 50.0).abs() < 0.1);
293    }
294}