Skip to main content

polydat_nodes/
partition.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Partition-typed stdlib nodes — SRD 71 §"Functions that consume
5//! partitions".
6//!
7//! Each node takes a [`polydat::iteration::cursor_partition::Partition`] value
8//! (carried through Polydat wires as `Value::Ext`) via the
9//! [`polydat::derive_support::Ext`] combinator and projects it into
10//! the u64 ordinal space the rest of the workload expects. These
11//! are the canonical primitives for "use the active partition's
12//! range in a per-cycle binding":
13//!
14//! - `cardinality` — partition size.
15//! - `start_of`    — partition's lower bound (inclusive).
16//! - `end_of`      — partition's upper bound (exclusive).
17//! - `idx_of`      — 0-based partition index.
18//! - `mod_in`      — modulo-mapped ordinal that stays inside
19//!   the partition.
20//! - `at`          — bounds-checked offset into the partition.
21//! - `clamp_in`    — saturating projection into the partition.
22//! - `random_in`   — hash-mapped ordinal inside the partition,
23//!   deterministic per seed.
24//! - `subdivide`   — split a partition into n near-equal
25//!   sub-partitions.
26//! - `partitions`  — parse a string spec into a `PartitionList`.
27//! - `partition_count`, `partition_at` — the length of a list and one
28//!   of its partitions by position.
29//!
30//! All of these are deterministic and JIT-friendly at the call
31//! site (the partition value is effectively-const for a scope
32//! activation, so the eval reduces to a small constant
33//! arithmetic expression).
34//!
35//! Naming note: `subdivide` here takes a *partition* and
36//! returns sub-partitions. The numeric comprehension generator
37//! that yields evenly spaced *values* over a `[start, end)`
38//! interval is `linear_starts(start, end, n)` (with
39//! `linear_steps` as its inclusive fence-post sibling) — see
40//! SRD 18c.
41
42use polydat::derive_support::Ext;
43use polydat::iteration::cursor_partition::{Partition, PartitionList};
44
45/// Number of ordinals in the partition.
46#[polydat::polydat_node(category = Arithmetic)]
47fn cardinality(partition: Ext<Partition>) -> u64 {
48    partition.cardinality()
49}
50
51/// Partition's start ordinal (inclusive).
52#[polydat::polydat_node(category = Arithmetic)]
53fn start_of(partition: Ext<Partition>) -> u64 {
54    partition.start_ord
55}
56
57/// Partition's end ordinal (exclusive).
58#[polydat::polydat_node(category = Arithmetic)]
59fn end_of(partition: Ext<Partition>) -> u64 {
60    partition.end_ord
61}
62
63/// 0-based position in the partition list.
64#[polydat::polydat_node(category = Arithmetic)]
65fn idx_of(partition: Ext<Partition>) -> u64 {
66    partition.idx
67}
68
69/// Total number of partitions in the list this partition was
70/// resolved as part of. `1` for a single-partition spec. The
71/// function spelling of the `partition_count` projection —
72/// pairs with `idx_of` for "i of n" labelling.
73#[polydat::polydat_node(category = Arithmetic)]
74fn count_of(partition: Ext<Partition>) -> u64 {
75    partition.count
76}
77
78/// `mod_in(n, p) = p.start_ord + (n mod cardinality(p))`. Maps an
79/// arbitrary integer (typically a per-cycle ordinal) into the
80/// partition's range, wrapping. Degenerate cardinality=0 returns
81/// the partition's start ordinal.
82#[polydat::polydat_node(category = Arithmetic)]
83fn mod_in(n: u64, partition: Ext<Partition>) -> u64 {
84    let card = partition.cardinality();
85    if card == 0 {
86        partition.start_ord
87    } else {
88        partition.start_ord + (n % card)
89    }
90}
91
92/// `at(p, i)` — bounds-checked `p.start_ord + i`. Use when
93/// iteration is meant to consume each ordinal exactly once.
94/// Panics at eval time if `i >= cardinality(p)`. Prefer `mod_in`
95/// for the wrapping case.
96#[polydat::polydat_node(category = Arithmetic)]
97fn at(partition: Ext<Partition>, i: u64) -> u64 {
98    let card = partition.cardinality();
99    if i >= card {
100        panic!(
101            "at({}, {i}): index out of range — partition #{} cardinality is {card}",
102            partition.start_ord, partition.idx
103        );
104    }
105    partition.start_ord + i
106}
107
108/// `clamp_in(n, p)` — saturating projection into the partition.
109/// `max(p.start_ord, min(n, p.end_ord - 1))`. Unlike `mod_in`,
110/// values outside the partition saturate at the boundary rather
111/// than wrapping. Degenerate cardinality=0 returns the start.
112#[polydat::polydat_node(category = Arithmetic)]
113fn clamp_in(n: u64, partition: Ext<Partition>) -> u64 {
114    if partition.cardinality() == 0 {
115        partition.start_ord
116    } else {
117        n.max(partition.start_ord).min(partition.end_ord - 1)
118    }
119}
120
121/// `random_in(p, seed)` — deterministic hash-mapped ordinal
122/// inside the partition: `p.start_ord + hash(seed) mod
123/// cardinality(p)`. Same SplitMix64 entropy source as `hash(...)`,
124/// so equal seeds always land on the same ordinal. Use for
125/// random-access patterns that must stay inside the active
126/// partition; prefer `mod_in` when sequential coverage matters.
127/// Degenerate cardinality=0 returns the partition's start.
128#[polydat::polydat_node(category = Hashing)]
129fn random_in(partition: Ext<Partition>, seed: u64) -> u64 {
130    let card = partition.cardinality();
131    if card == 0 {
132        partition.start_ord
133    } else {
134        partition.start_ord + crate::hash::splitmix64_u64(seed) % card
135    }
136}
137
138/// `subdivide(p, n)` — split a partition into `n` contiguous
139/// sub-partitions whose sizes differ by at most one ordinal.
140/// Indices restart at 0; `base_extent` propagates from the
141/// parent; the percentage fields interpolate the parent's
142/// span. Boundaries match the `*/N` spec tail token exactly
143/// (both route through the same splitter). Panics at eval time
144/// when `n` is 0 or exceeds the partition's cardinality —
145/// every sub-partition must be non-empty.
146#[polydat::polydat_node(category = Arithmetic)]
147fn subdivide(partition: Ext<Partition>, n: u64) -> Ext<PartitionList> {
148    let parts = polydat::iteration::cursor_partition::subdivide_partition(&partition, n)
149        .unwrap_or_else(|e| panic!("{e}"));
150    Ext(PartitionList(std::sync::Arc::new(parts)))
151}
152
153/// Parse a string spec into a `PartitionList`. The base extent
154/// for resolution comes from a constant arg (default 100, so
155/// pure-percentage specs produce partitions in [0, 100) ordinal
156/// space). Useful for constructing partition values inline when
157/// a cursor's `over` clause needs an explicit list.
158#[polydat::polydat_node(category = Arithmetic)]
159fn partitions(
160    spec: &str,
161    #[poly_default(100u64)] extent: polydat::derive_support::Const<u64>,
162) -> Ext<PartitionList> {
163    let parsed = polydat::iteration::cursor_partition::parse(spec)
164        .unwrap_or_else(|e| panic!("partitions: bad spec `{spec}`: {e}"));
165    let parts = polydat::iteration::cursor_partition::resolve(&parsed, 0, *extent)
166        .unwrap_or_else(|e| panic!("partitions: resolve failed: {e}"));
167    Ext(PartitionList(std::sync::Arc::new(parts)))
168}
169
170/// Number of partitions in a list.
171#[polydat::polydat_node(category = Arithmetic)]
172fn partition_count(list: Ext<PartitionList>) -> u64 {
173    list.0.len() as u64
174}
175
176/// The `i`-th partition of a list, by position. Panics when `i` is
177/// out of range, like `at` does for ordinals.
178#[polydat::polydat_node(category = Arithmetic)]
179fn partition_at(list: Ext<PartitionList>, i: u64) -> Ext<Partition> {
180    let n = list.0.len() as u64;
181    if i >= n {
182        panic!("partition_at: index {i} out of range for a list of {n} partition(s)");
183    }
184    Ext(list.0.0[i as usize])
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use polydat::ast::{PolydatNode, Value};
191
192    fn fixture(idx: u64, start: u64, end: u64) -> Partition {
193        Partition {
194            idx,
195            count: idx + 1,
196            start_ord: start,
197            end_ord: end,
198            start_pct: 0.0,
199            end_pct: 0.0,
200            base_extent: end,
201        }
202    }
203
204    #[test]
205    fn cardinality_returns_end_minus_start() {
206        let node = Cardinality::new();
207        let mut out = [Value::None];
208        node.eval(&[Value::from_partition(fixture(0, 100, 500))], &mut out);
209        assert_eq!(out[0].as_u64(), 400);
210    }
211
212    #[test]
213    fn start_of_returns_start_ord() {
214        let node = StartOf::new();
215        let mut out = [Value::None];
216        node.eval(&[Value::from_partition(fixture(2, 100, 500))], &mut out);
217        assert_eq!(out[0].as_u64(), 100);
218    }
219
220    #[test]
221    fn end_of_returns_end_ord() {
222        let node = EndOf::new();
223        let mut out = [Value::None];
224        node.eval(&[Value::from_partition(fixture(0, 100, 500))], &mut out);
225        assert_eq!(out[0].as_u64(), 500);
226    }
227
228    #[test]
229    fn idx_of_returns_idx() {
230        let node = IdxOf::new();
231        let mut out = [Value::None];
232        node.eval(&[Value::from_partition(fixture(3, 100, 500))], &mut out);
233        assert_eq!(out[0].as_u64(), 3);
234    }
235
236    #[test]
237    fn mod_in_wraps_inside_partition() {
238        let node = ModIn::new();
239        let mut out = [Value::None];
240        let p = Value::from_partition(fixture(0, 100, 200));
241        for (n, expected) in [(0, 100), (50, 150), (99, 199), (100, 100), (250, 150)] {
242            node.eval(&[Value::U64(n), p.clone()], &mut out);
243            assert_eq!(out[0].as_u64(), expected, "mod_in({n}) over [100, 200)");
244        }
245    }
246
247    #[test]
248    fn mod_in_zero_cardinality_returns_start() {
249        let node = ModIn::new();
250        let mut out = [Value::None];
251        let p = Value::from_partition(fixture(0, 100, 100));
252        node.eval(&[Value::U64(42), p], &mut out);
253        assert_eq!(out[0].as_u64(), 100);
254    }
255
256    #[test]
257    fn at_offset_within_bounds() {
258        let node = At::new();
259        let mut out = [Value::None];
260        let p = Value::from_partition(fixture(0, 100, 200));
261        node.eval(&[p, Value::U64(15)], &mut out);
262        assert_eq!(out[0].as_u64(), 115);
263    }
264
265    #[test]
266    #[should_panic(expected = "index out of range")]
267    fn at_offset_out_of_range_panics() {
268        let node = At::new();
269        let mut out = [Value::None];
270        let p = Value::from_partition(fixture(0, 100, 200));
271        node.eval(&[p, Value::U64(100)], &mut out);
272    }
273
274    #[test]
275    fn clamp_in_saturates_at_bounds() {
276        let node = ClampIn::new();
277        let mut out = [Value::None];
278        let p = Value::from_partition(fixture(0, 100, 200));
279        for (n, expected) in [
280            (50, 100),
281            (100, 100),
282            (150, 150),
283            (199, 199),
284            (200, 199),
285            (1000, 199),
286        ] {
287            node.eval(&[Value::U64(n), p.clone()], &mut out);
288            assert_eq!(out[0].as_u64(), expected, "clamp_in({n}) over [100, 200)");
289        }
290    }
291
292    #[test]
293    fn random_in_deterministic_and_bounded() {
294        let node = RandomIn::new();
295        let mut out = [Value::None];
296        let p = Value::from_partition(fixture(0, 100, 200));
297        let mut first = Vec::new();
298        for seed in 0..32u64 {
299            node.eval(&[p.clone(), Value::U64(seed)], &mut out);
300            let v = out[0].as_u64();
301            assert!(
302                (100..200).contains(&v),
303                "random_in(seed={seed}) = {v} outside [100, 200)"
304            );
305            first.push(v);
306        }
307        // Deterministic: same seeds, same ordinals.
308        for (seed, expected) in first.iter().enumerate() {
309            node.eval(&[p.clone(), Value::U64(seed as u64)], &mut out);
310            assert_eq!(out[0].as_u64(), *expected);
311        }
312        // Not constant across seeds.
313        assert!(first.windows(2).any(|w| w[0] != w[1]));
314    }
315
316    #[test]
317    fn random_in_zero_cardinality_returns_start() {
318        let node = RandomIn::new();
319        let mut out = [Value::None];
320        node.eval(
321            &[Value::from_partition(fixture(0, 100, 100)), Value::U64(7)],
322            &mut out,
323        );
324        assert_eq!(out[0].as_u64(), 100);
325    }
326
327    #[test]
328    fn partition_at_and_count_index_a_list_by_position() {
329        let list = Value::Ext(Box::new(PartitionList::new(vec![
330            fixture(0, 0, 10),
331            fixture(1, 10, 25),
332            fixture(2, 25, 100),
333        ])));
334        let mut out = [Value::None];
335        PartitionCount::new().eval(std::slice::from_ref(&list), &mut out);
336        assert_eq!(out[0], Value::U64(3));
337        PartitionAt::new().eval(&[list.clone(), Value::U64(1)], &mut out);
338        let p = out[0].as_partition().expect("Partition");
339        assert_eq!((p.start_ord, p.end_ord), (10, 25));
340    }
341
342    #[test]
343    #[should_panic(expected = "out of range")]
344    fn partition_at_past_the_end_panics() {
345        let list = Value::Ext(Box::new(PartitionList::new(vec![fixture(0, 0, 10)])));
346        let mut out = [Value::None];
347        PartitionAt::new().eval(&[list, Value::U64(1)], &mut out);
348    }
349    #[test]
350    fn subdivide_splits_into_near_equal_contiguous_parts() {
351        let node = Subdivide::new();
352        let mut out = [Value::None];
353        let parent = Partition {
354            idx: 1,
355            count: 2,
356            start_ord: 900,
357            end_ord: 1000,
358            start_pct: 90.0,
359            end_pct: 100.0,
360            base_extent: 1000,
361        };
362        node.eval(&[Value::from_partition(parent), Value::U64(10)], &mut out);
363        let list = out[0].as_partition_list().expect("PartitionList");
364        assert_eq!(list.len(), 10);
365        let subs = list.as_slice();
366        assert_eq!(subs[0].start_ord, 900);
367        assert_eq!(subs[9].end_ord, 1000);
368        for (i, s) in subs.iter().enumerate() {
369            assert_eq!(s.idx, i as u64, "indices restart at 0");
370            assert_eq!(s.cardinality(), 10);
371            assert_eq!(s.base_extent, 1000, "base_extent propagates");
372        }
373        for w in subs.windows(2) {
374            assert_eq!(w[0].end_ord, w[1].start_ord, "contiguous");
375        }
376        // Percentage fields interpolate the parent's span.
377        assert!((subs[0].start_pct - 90.0).abs() < 1e-9);
378        assert!((subs[4].end_pct - 95.0).abs() < 1e-9);
379        assert!((subs[9].end_pct - 100.0).abs() < 1e-9);
380    }
381
382    #[test]
383    #[should_panic(expected = "non-empty sub-partitions")]
384    fn subdivide_finer_than_cardinality_panics() {
385        let node = Subdivide::new();
386        let mut out = [Value::None];
387        node.eval(
388            &[Value::from_partition(fixture(0, 0, 5)), Value::U64(10)],
389            &mut out,
390        );
391    }
392
393    #[test]
394    #[should_panic(expected = "must be >= 1")]
395    fn subdivide_zero_count_panics() {
396        let node = Subdivide::new();
397        let mut out = [Value::None];
398        node.eval(
399            &[Value::from_partition(fixture(0, 0, 100)), Value::U64(0)],
400            &mut out,
401        );
402    }
403
404    #[test]
405    fn partitions_node_resolves_spec_against_extent() {
406        let node = Partitions::new(1000);
407        let mut out = [Value::None];
408        node.eval(&[Value::Str("linear:4".into())], &mut out);
409        let list = out[0].as_partition_list().expect("PartitionList");
410        assert_eq!(list.len(), 4);
411        for (i, p) in list.as_slice().iter().enumerate() {
412            assert_eq!(p.idx, i as u64);
413            assert_eq!(p.cardinality(), 250);
414        }
415    }
416
417    #[test]
418    fn partitions_node_handles_form1_single_range() {
419        let node = Partitions::new(1000);
420        let mut out = [Value::None];
421        node.eval(&[Value::Str("0..50%".into())], &mut out);
422        let list = out[0].as_partition_list().expect("PartitionList");
423        assert_eq!(list.len(), 1);
424        assert_eq!(list.as_slice()[0].start_ord, 0);
425        assert_eq!(list.as_slice()[0].end_ord, 500);
426    }
427}