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 (the `dist_*`/`icd_*` nodes sample a
124/// `LutF64` the same way), custom transfer curves, and empirical data
125/// all route through this node at runtime.
126/// The lookup is O(1): a single array index plus one linear
127/// interpolation, with no branching on distribution type.
128///
129/// JIT level: P3 (`JitOp::LutSampleConst`: an extern call with the LUT
130/// pointer and length from `jit_constants`).
131pub struct LutSample {
132    meta: NodeMeta,
133    table: LutF64,
134}
135
136impl LutSample {
137    /// Create from a pre-built LUT.
138    pub fn new(table: LutF64) -> Self {
139        Self {
140            meta: NodeMeta {
141                name: "lut_sample".into(),
142                outs: vec![Port::new("output", PortType::F64)],
143                ins: vec![Slot::Wire(Port::new("input", PortType::F64))],
144            },
145            table,
146        }
147    }
148}
149
150impl PolydatNode for LutSample {
151    fn meta(&self) -> &NodeMeta {
152        &self.meta
153    }
154
155    fn eval(&self, inputs: &[Value], outputs: &mut [Value]) {
156        outputs[0] = Value::F64(self.table.sample(inputs[0].as_f64()));
157    }
158
159    fn compiled_u64(&self) -> Option<CompiledU64Op> {
160        // Capture the pointer as usize to satisfy Send+Sync.
161        // Safety: the LUT is immutable after construction and outlives
162        // the closure (both are owned by the same PolydatNode).
163        let lut_addr = self.table.lut.as_ptr() as usize;
164        let lut_len = self.table.lut.len();
165        Some(Box::new(move |inputs, outputs| {
166            let u = f64::from_bits(inputs[0]).clamp(0.0, 1.0);
167            let n = (lut_len - 1) as f64;
168            let pos = u * n;
169            let idx = (pos as usize).min(lut_len - 2);
170            let frac = pos - idx as f64;
171            let result = unsafe {
172                let ptr = lut_addr as *const f64;
173                let a = *ptr.add(idx);
174                let b = *ptr.add(idx + 1);
175                a * (1.0 - frac) + b * frac
176            };
177            outputs[0] = result.to_bits();
178        }))
179    }
180
181    fn jit_constants(&self) -> Vec<u64> {
182        vec![self.table.lut.as_ptr() as u64, self.table.lut.len() as u64]
183    }
184}
185
186// `EmpiricalSample` parses the spec at construction time into a
187// `LutF64` (`#[poly_const]` setup); eval samples from the cached
188// table.
189
190impl polydat::derive_support::PolydatSetup for LutF64 {}
191
192/// Parse a free-form spec ("1.0 2.5 7" or "1.0,2.5,7") into a
193/// sorted `LutF64`. Single-call setup invoked by the macro.
194fn parse_empirical_lut(spec: &str) -> LutF64 {
195    let mut values: Vec<f64> = spec
196        .split([' ', ',', ';'])
197        .filter(|s| !s.trim().is_empty())
198        .map(|s| {
199            s.trim()
200                .parse::<f64>()
201                .expect("invalid empirical data point")
202        })
203        .collect();
204    assert!(
205        values.len() >= 2,
206        "empirical distribution needs at least 2 data points"
207    );
208    values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
209    LutF64::from_values(&values)
210}
211
212/// Sample from an empirical distribution defined by a list of
213/// data points. The data points become the LUT entries
214fn dist_empirical_jit_constants(node: &DistEmpirical) -> Vec<u64> {
215    vec![node.table.as_ptr() as u64, node.table.len() as u64]
216}
217
218/// Sample from an empirical distribution defined by a list of
219/// data points. The data points become the LUT entries
220/// (sorted); linear interpolation gives continuous sampling.
221/// Input should be in `[0, 1]` (from `unit_interval`).
222#[polydat::polydat_node(category = Probability, jit_constants = dist_empirical_jit_constants)]
223fn dist_empirical(
224    input: f64,
225    spec: polydat::derive_support::Const<&str>,
226    #[poly_const(parse_empirical_lut, from = spec)] table: &LutF64,
227) -> f64 {
228    table.sample(input)
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn lut_identity() {
237        let table = LutF64::from_fn(|p| p, 100);
238        assert!((table.sample(0.0) - 0.0).abs() < 1e-10);
239        assert!((table.sample(0.5) - 0.5).abs() < 0.01);
240        assert!((table.sample(1.0) - 1.0).abs() < 1e-10);
241    }
242
243    #[test]
244    fn lut_quadratic() {
245        let table = LutF64::from_fn(|p| p * p, 1000);
246        assert!((table.sample(0.5) - 0.25).abs() < 0.001);
247        assert!((table.sample(0.0) - 0.0).abs() < 1e-10);
248        assert!((table.sample(1.0) - 1.0).abs() < 0.001);
249    }
250
251    #[test]
252    fn lut_clamps_input() {
253        let table = LutF64::from_fn(|p| p * 10.0, 100);
254        // Negative input clamps to 0
255        assert!((table.sample(-0.5) - 0.0).abs() < 1e-10);
256        // Input > 1 clamps to 1
257        assert!((table.sample(1.5) - 10.0).abs() < 1e-10);
258    }
259
260    #[test]
261    fn lut_sanitizes_infinities() {
262        let table = LutF64::from_fn(
263            |p| {
264                if !(0.01..=0.99).contains(&p) {
265                    f64::INFINITY
266                } else {
267                    p
268                }
269            },
270            100,
271        );
272        // Edges should be replaced with nearest finite values
273        assert!(table.sample(0.0).is_finite());
274        assert!(table.sample(1.0).is_finite());
275    }
276
277    #[test]
278    fn lut_from_values() {
279        let table = LutF64::from_values(&[0.0, 5.0, 10.0]);
280        assert!((table.sample(0.0) - 0.0).abs() < 1e-10);
281        assert!((table.sample(0.5) - 5.0).abs() < 1e-10);
282        assert!((table.sample(1.0) - 10.0).abs() < 1e-10);
283        // Interpolation at 0.25 should give 2.5
284        assert!((table.sample(0.25) - 2.5).abs() < 1e-10);
285    }
286
287    #[test]
288    fn lut_node_eval() {
289        let table = LutF64::from_fn(|p| p * 100.0, 1000);
290        let node = LutSample::new(table);
291        let mut out = [Value::None];
292        node.eval(&[Value::F64(0.5)], &mut out);
293        assert!((out[0].as_f64() - 50.0).abs() < 0.1);
294    }
295}