Skip to main content

polydat_nodes/
random.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Non-deterministic random data generators for prototyping and testing.
5//!
6//! These nodes use thread-local RNG and produce different outputs on
7//! each call regardless of input coordinates. They are NOT reproducible
8//! across runs. Use the deterministic hash-based nodes for production
9//! workloads.
10//!
11//! All "random" nodes are 0→1 (no inputs) to make the non-deterministic
12//! nature clear. The "hashed line/extract" nodes are 1→1 (deterministic,
13//! coordinate-driven) and use the bundled text data files.
14
15use std::cell::RefCell;
16
17use xxhash_rust::xxh3::xxh3_64;
18
19// =================================================================
20// Bundled data files (included at compile time)
21// =================================================================
22
23/// ~93KB of Lorem Ipsum text from nosqlbench's data files.
24pub static LOREM_IPSUM: &str = include_str!("../data/lorem_ipsum_full.txt");
25/// First names
26pub static NAMES: &str = include_str!("../data/names.txt");
27/// Last names
28pub static LASTNAMES: &str = include_str!("../data/lastnames.txt");
29/// Career titles
30pub static CAREERS: &str = include_str!("../data/careers.txt");
31/// Company names
32pub static COMPANIES: &str = include_str!("../data/companies.txt");
33/// Variable/metric words
34pub static VARIABLE_WORDS: &str = include_str!("../data/variable_words.txt");
35
36// =================================================================
37// Thread-local xorshift64 PRNG
38// =================================================================
39
40thread_local! {
41    static RNG: RefCell<u64> = RefCell::new(
42        std::time::SystemTime::now()
43            .duration_since(std::time::UNIX_EPOCH)
44            .unwrap()
45            .as_nanos() as u64
46    );
47}
48
49fn next_u64() -> u64 {
50    RNG.with(|r| {
51        let mut s = *r.borrow();
52        s ^= s << 13;
53        s ^= s >> 7;
54        s ^= s << 17;
55        *r.borrow_mut() = s;
56        s
57    })
58}
59
60fn next_f64() -> f64 {
61    next_u64() as f64 / u64::MAX as f64
62}
63
64// =================================================================
65// Non-deterministic random nodes (0→1)
66// =================================================================
67
68/// Random u64 in [min, max). `range = max - min` is computed inline
69/// per call (non-det node, per-call subtraction is noise).
70#[polydat::polydat_node(
71    category = Probability,
72    purity = Nondeterministic("thread-local PRNG"),
73)]
74fn random_range(
75    #[poly_default(0u64)] min: polydat::derive_support::Const<u64>,
76    #[poly_default(100u64)] max: polydat::derive_support::Const<u64>,
77) -> u64 {
78    // Saturate the range to a non-zero value so a misconfigured
79    // workload (min == max, or min > max) doesn't trap on the
80    // modulus. `max.saturating_sub(min)` is 0 when min >= max.
81    let range = max.saturating_sub(*min).max(1);
82    *min + (next_u64() % range)
83}
84
85/// Random f64 in [min, max).
86#[polydat::polydat_node(
87    category = Probability,
88    purity = Nondeterministic("thread-local PRNG"),
89)]
90fn random_f64(
91    #[poly_default(0.0f64)] min: polydat::derive_support::Const<f64>,
92    #[poly_default(1.0f64)] max: polydat::derive_support::Const<f64>,
93) -> f64 {
94    *min + next_f64() * (*max - *min)
95}
96
97/// Random byte buffer.
98#[polydat::polydat_node(
99    category = Probability,
100    purity = Nondeterministic("thread-local PRNG"),
101)]
102fn random_bytes(#[poly_default(8u64)] size: polydat::derive_support::Const<u64>) -> Vec<u8> {
103    let sz = *size as usize;
104    let mut buf = Vec::with_capacity(sz);
105    while buf.len() < sz {
106        let take = (sz - buf.len()).min(8);
107        buf.extend_from_slice(&next_u64().to_le_bytes()[..take]);
108    }
109    buf
110}
111
112/// Expanded character set for `random_string`, parsed once per
113/// node instance from the charset spec via `parse`.
114pub struct Charset(pub Vec<char>);
115
116impl polydat::derive_support::PolydatSetup for Charset {}
117
118impl Charset {
119    /// Single-call setup. The `#[polydat_node]` macro invokes
120    /// this exactly once in the generated `RandomString::new()`.
121    pub fn parse(spec: &str) -> Self {
122        Self(parse_charset(spec))
123    }
124}
125
126/// Random string from a character set.
127///
128/// The charset spec (e.g. `"A-Za-z0-9"`) is expanded once at
129/// construction; each call draws `length` characters from it.
130/// For deterministic output, use `combinations` or `char_buf`.
131#[polydat::polydat_node(
132    category = Probability,
133    purity = Nondeterministic("thread-local PRNG"),
134)]
135fn random_string(
136    #[poly_default("A-Za-z0-9")] charset: polydat::derive_support::Const<&str>,
137    #[poly_default(8u64)] length: polydat::derive_support::Const<u64>,
138    #[poly_const(Charset::parse, from = charset)] chars: &Charset,
139) -> String {
140    let _ = charset; // expanded into `chars` at construction
141    let chars = &chars.0;
142    if chars.is_empty() {
143        return String::new();
144    }
145    (0..*length)
146        .map(|_| chars[(next_u64() as usize) % chars.len()])
147        .collect()
148}
149
150/// Random boolean with probability of true.
151#[polydat::polydat_node(
152    category = Probability,
153    purity = Nondeterministic("thread-local PRNG"),
154)]
155fn random_bool(#[poly_default(0.5f64)] probability: polydat::derive_support::Const<f64>) -> bool {
156    let threshold = (probability.clamp(0.0, 1.0) * u64::MAX as f64) as u64;
157    next_u64() < threshold
158}
159
160impl RandomString {
161    /// A random string node over `A-Za-z0-9` of the given length.
162    pub fn alphanumeric(length: u64) -> Self {
163        Self::new("A-Za-z0-9".to_string(), length)
164    }
165}
166
167// =================================================================
168// Deterministic text extraction nodes (1→1, hash-based)
169// =================================================================
170
171/// Extract a substring from bundled lorem ipsum text using a hash-based
172/// offset. Deterministic: same input → same extract.
173///
174/// Signature: `hashed_lorem_extract(input: u64, min_len: u64, max_len: u64) -> String`
175///
176/// Equivalent to nosqlbench's `HashedLoremExtractToString`.
177#[polydat::polydat_node(category = String)]
178fn hashed_lorem_extract(
179    input: u64,
180    min_len: polydat::derive_support::Const<u64>,
181    max_len: polydat::derive_support::Const<u64>,
182) -> String {
183    let min_len = *min_len as usize;
184    let max_len = *max_len as usize;
185    let len_range = max_len.saturating_sub(min_len) + 1;
186    let extract_len = min_len + ((input as usize) % len_range);
187    let max_offset = LOREM_IPSUM.len().saturating_sub(extract_len);
188    let h2 = xxh3_64(&input.to_le_bytes());
189    let offset = if max_offset > 0 {
190        (h2 as usize) % (max_offset + 1)
191    } else {
192        0
193    };
194    let end = (offset + extract_len).min(LOREM_IPSUM.len());
195    // Align to char boundaries
196    let start = LOREM_IPSUM.floor_char_boundary(offset);
197    let end = LOREM_IPSUM.ceil_char_boundary(end);
198    LOREM_IPSUM[start..end].to_string()
199}
200
201/// Pre-split list of non-empty lines from a bundled text source.
202/// Derived state for `hashed_line_to_string`, computed once per
203/// node instance via `split_lines`.
204pub struct HashedLines(pub Vec<String>);
205
206impl polydat::derive_support::PolydatSetup for HashedLines {}
207
208impl HashedLines {
209    /// Single-call setup. The `#[polydat_node]` macro invokes
210    /// this exactly once in the generated `HashedLineToString::new()`.
211    pub fn split_lines(text: &str) -> Self {
212        let lines: Vec<String> = text
213            .lines()
214            .map(|l| l.to_string())
215            .filter(|l| !l.is_empty())
216            .collect();
217        assert!(!lines.is_empty(), "text source must have at least one line");
218        Self(lines)
219    }
220}
221
222/// Select a deterministic line from a bundled text source using
223/// the input hash as an index. Deterministic: same input → same line.
224///
225/// Signature: `hashed_line_to_string(input: u64, source: &str) -> String`
226///
227/// Equivalent to nosqlbench's `HashedLineToString`. The text source
228/// is split into lines at node-construction time (setup-derived
229/// state).
230#[polydat::polydat_node(category = String)]
231fn hashed_line_to_string(
232    input: u64,
233    source: polydat::derive_support::Const<&str>,
234    #[poly_const(HashedLines::split_lines, from = source)] lines: &HashedLines,
235) -> String {
236    let _ = source;
237    let idx = (input as usize) % lines.0.len();
238    lines.0[idx].clone()
239}
240
241impl HashedLineToString {
242    /// From bundled first names.
243    pub fn names() -> Self {
244        Self::new(NAMES.to_string())
245    }
246    /// From bundled last names.
247    pub fn lastnames() -> Self {
248        Self::new(LASTNAMES.to_string())
249    }
250    /// From bundled careers.
251    pub fn careers() -> Self {
252        Self::new(CAREERS.to_string())
253    }
254    /// From bundled company names.
255    pub fn companies() -> Self {
256        Self::new(COMPANIES.to_string())
257    }
258}
259
260fn parse_charset(spec: &str) -> Vec<char> {
261    let mut chars = Vec::new();
262    let spec_chars: Vec<char> = spec.chars().collect();
263    let mut i = 0;
264    while i < spec_chars.len() {
265        if i + 2 < spec_chars.len() && spec_chars[i + 1] == '-' {
266            for c in spec_chars[i]..=spec_chars[i + 2] {
267                chars.push(c);
268            }
269            i += 3;
270        } else {
271            chars.push(spec_chars[i]);
272            i += 1;
273        }
274    }
275    chars
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use polydat::ast::{PolydatNode, Value};
282
283    #[test]
284    fn lorem_ipsum_bundled() {
285        assert!(LOREM_IPSUM.len() > 90_000, "lorem ipsum should be ~93KB");
286        assert!(LOREM_IPSUM.starts_with("Lorem ipsum"));
287    }
288
289    #[test]
290    fn names_bundled() {
291        assert!(!NAMES.is_empty());
292        assert!(NAMES.lines().count() > 10);
293    }
294
295    #[test]
296    fn random_range_bounded() {
297        let node = RandomRange::new(10, 20);
298        let mut out = [Value::None];
299        for _ in 0..1000 {
300            node.eval(&[], &mut out);
301            assert!((10..20).contains(&out[0].as_u64()));
302        }
303    }
304
305    #[test]
306    fn random_f64_bounded() {
307        let node = RandomF64::new(1.0, 5.0);
308        let mut out = [Value::None];
309        for _ in 0..1000 {
310            node.eval(&[], &mut out);
311            let v = out[0].as_f64();
312            assert!((1.0..5.0).contains(&v), "out of range: {v}");
313        }
314    }
315
316    #[test]
317    fn random_string_charset() {
318        let node = RandomString::alphanumeric(20);
319        let mut out = [Value::None];
320        node.eval(&[], &mut out);
321        assert_eq!(out[0].as_str().len(), 20);
322        assert!(out[0].as_str().chars().all(|c| c.is_ascii_alphanumeric()));
323    }
324
325    #[test]
326    fn hashed_lorem_extract_deterministic() {
327        let node = HashedLoremExtract::new(50, 100);
328        let mut out1 = [Value::None];
329        let mut out2 = [Value::None];
330        node.eval(&[Value::U64(42)], &mut out1);
331        node.eval(&[Value::U64(42)], &mut out2);
332        assert_eq!(out1[0].as_str(), out2[0].as_str());
333    }
334
335    #[test]
336    fn hashed_lorem_extract_size_range() {
337        let node = HashedLoremExtract::new(20, 50);
338        let mut out = [Value::None];
339        for i in 0..100u64 {
340            let h = xxh3_64(&i.to_le_bytes());
341            node.eval(&[Value::U64(h)], &mut out);
342            let len = out[0].as_str().len();
343            assert!((19..=55).contains(&len), "len={len}"); // char boundary wiggle
344        }
345    }
346
347    #[test]
348    fn hashed_lorem_extract_varies() {
349        let node = HashedLoremExtract::new(10, 20);
350        let mut out1 = [Value::None];
351        let mut out2 = [Value::None];
352        let h1 = xxh3_64(&0u64.to_le_bytes());
353        let h2 = xxh3_64(&1u64.to_le_bytes());
354        node.eval(&[Value::U64(h1)], &mut out1);
355        node.eval(&[Value::U64(h2)], &mut out2);
356        assert_ne!(out1[0].as_str(), out2[0].as_str());
357    }
358
359    #[test]
360    fn hashed_line_names() {
361        let node = HashedLineToString::names();
362        let mut out = [Value::None];
363        let h = xxh3_64(&42u64.to_le_bytes());
364        node.eval(&[Value::U64(h)], &mut out);
365        assert!(!out[0].as_str().is_empty());
366    }
367
368    #[test]
369    fn hashed_line_careers() {
370        let node = HashedLineToString::careers();
371        let mut out = [Value::None];
372        let h = xxh3_64(&42u64.to_le_bytes());
373        node.eval(&[Value::U64(h)], &mut out);
374        assert!(!out[0].as_str().is_empty());
375    }
376
377    #[test]
378    fn hashed_line_deterministic() {
379        let node = HashedLineToString::names();
380        let mut out1 = [Value::None];
381        let mut out2 = [Value::None];
382        node.eval(&[Value::U64(12345)], &mut out1);
383        node.eval(&[Value::U64(12345)], &mut out2);
384        assert_eq!(out1[0].as_str(), out2[0].as_str());
385    }
386}