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