ndslice/strategy.rs
1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 * All rights reserved.
4 *
5 * This source code is licensed under the BSD-style license found in the
6 * LICENSE file in the root directory of this source tree.
7 */
8
9//! Property-based generators for [`Selection`] and related types.
10//!
11//! These strategies are used in `proptest`-based tests to construct
12//! randomized selection expressions for testing evaluation, routing,
13//! and normalization logic.
14//!
15//! The main entry point is [`gen_selection(depth)`], which generates
16//! a structurally diverse [`Selection`] of bounded depth, supporting
17//! the `True`, `Range`, `All`, `Union`, and `Intersection`
18//! constructors.
19//!
20//! Example usage:
21//!
22//! ```
23//! use ndslice::strategy::gen_selection;
24//! use proptest::prelude::*;
25//!
26//! proptest! {
27//! #[test]
28//! fn test_selection(s in gen_selection(3)) {
29//! // Use `s` as input to evaluation or routing tests
30//! }
31//! }
32//! ```
33
34use proptest::prelude::*;
35
36use crate::Slice;
37use crate::selection::EvalOpts;
38use crate::selection::Selection;
39use crate::selection::dsl;
40use crate::shape::Range;
41use crate::view::Extent;
42use crate::view::Region;
43
44/// Generates a random [`Slice`] with up to `max_dims` dimensions,
45/// where each dimension has a size between 1 and `max_len`
46/// (inclusive).
47///
48/// The slice is constructed using standard row-major layout with no
49/// offset, making it suitable for use in evaluation, routing, and
50/// normalization tests.
51///
52/// This generator is used in property-based tests to provide diverse
53/// input shapes for selection and routing logic.
54///
55/// # Parameters
56///
57/// - `max_dims`: Maximum number of dimensions in the generated slice.
58/// - `max_len`: Maximum size per dimension.
59///
60/// # Example
61///
62/// ```
63/// use ndslice::strategy::gen_slice;
64/// use proptest::prelude::*;
65///
66/// proptest! {
67/// fn test_slice_generation(slice in gen_slice(4, 8)) {
68/// assert!(!slice.sizes().is_empty());
69/// }
70/// }
71/// ```
72pub fn gen_slice(max_dims: usize, max_len: usize) -> impl Strategy<Value = Slice> {
73 prop::collection::vec(1..=max_len, 1..=max_dims).prop_map(Slice::new_row_major)
74}
75
76/// Generate a random [`Extent`] with `dims` dimensions, where each
77/// size is in `1..=max_len`.
78///
79/// For example, `gen_extent(1..=4, 1..=8)` generates extents like:
80/// - x=3
81/// - x=2, y=4
82/// - x=2, y=4, z=1, w=5
83pub fn gen_extent(
84 dims: std::ops::RangeInclusive<usize>,
85 max_len: usize,
86) -> impl Strategy<Value = Extent> {
87 prop::collection::vec(1..=max_len, dims).prop_map(|sizes| {
88 let labels = (0..sizes.len())
89 .map(|i| format!("d/{}", i))
90 .collect::<Vec<_>>();
91 Extent::new(labels, sizes).unwrap()
92 })
93}
94
95/// Generate a random [`Region`] strategy for property tests.
96///
97/// This builds on [`gen_extent`], producing a region with the same
98/// randomly chosen dimensionality and sizes, but wrapped as a full
99/// [`Region`] (with labels and strides).
100///
101/// - `dims`: inclusive range of allowed dimensionalities (e.g.
102/// `1..=4`)
103/// - `max_len`: maximum size of any dimension
104pub fn gen_region(
105 dims: std::ops::RangeInclusive<usize>,
106 max_len: usize,
107) -> impl proptest::strategy::Strategy<Value = Region> {
108 gen_extent(dims, max_len).prop_map(Into::into)
109}
110
111/// Generate a random [`Region`] strategy with striding for property
112/// tests.
113///
114/// Similar to [`gen_region`], but each dimension may additionally use
115/// a non-unit step. This produces regions whose underlying slice has
116/// non-contiguous strides, useful for testing strided layouts.
117///
118/// - `dims`: inclusive range of allowed dimensionalities (e.g.
119/// `1..=4`)
120/// - `max_len`: maximum size of any dimension
121/// - `max_step`: maximum stride step size applied to each dimension
122/// - `_max_offset`: reserved for future use (currently ignored)
123pub fn gen_region_strided(
124 dims: std::ops::RangeInclusive<usize>,
125 max_len: usize,
126 max_step: usize,
127 _max_offset: usize,
128) -> impl Strategy<Value = Region> {
129 use crate::view::ViewExt;
130
131 prop::collection::vec(1..=max_len, dims)
132 .prop_flat_map(move |sizes| {
133 let n = sizes.len();
134 let labels: Vec<String> = (0..n).map(|i| format!("d/{}", i)).collect();
135
136 let steps_raw = prop::collection::vec(1..=max_step.max(1), n);
137 let begins_unclamped = prop::collection::vec(proptest::num::usize::ANY, n);
138
139 (Just((labels, sizes)), steps_raw, begins_unclamped)
140 })
141 .prop_map(move |((labels, sizes), steps_raw, begins_unclamped)| {
142 // 1) Make steps obey the divisibility chain: step[i] %
143 // step[i+1] == 0
144 let mut steps = steps_raw;
145 if !steps.is_empty() {
146 // innermost is free
147 let last = steps.len() - 1;
148 steps[last] = steps[last].max(1).min(max_step.max(1));
149 // Each outer step is an integer multiple of the next
150 // inner step.
151 for i in (0..last).rev() {
152 let inner = steps[i + 1].max(1);
153 let max_mult = (max_step / inner).max(1);
154 // Clamp current to be a multiple of `inner`
155 // within [inner, max_step].
156 let m = ((steps[i].max(1) - 1) % max_mult) + 1;
157 steps[i] = inner.saturating_mul(m);
158 }
159 }
160
161 // 2) Build from a row-major region and compose per-axis
162 // ranges
163 let mut region: Region = Extent::new(labels.clone(), sizes.clone()).unwrap().into();
164 for i in 0..sizes.len() {
165 let begin = begins_unclamped[i] % sizes[i]; // in [0, size-1]
166 let step = steps[i].max(1);
167 region = region
168 .range(&labels[i], Range(begin, None, step))
169 .expect("range stayed rectangular");
170 }
171 region
172 })
173}
174
175/// Generates a pair `(base, subview)` where:
176/// - `base` is a randomly shaped row-major `Slice`,
177/// - `subview` is a valid rectangular region within `base`.
178pub fn gen_slice_and_subview(
179 max_dims: usize,
180 max_len: usize,
181) -> impl Strategy<Value = (Slice, Slice)> {
182 assert!(max_dims <= 8, "Supports up to 4 dimensions explicitly");
183
184 gen_slice(max_dims, max_len).prop_flat_map(|base| {
185 let sizes = base.sizes().to_vec();
186
187 // Strategy per dimension
188 let dim_strat = |extent| {
189 if extent == 0 {
190 Just((0, 0)).boxed()
191 } else {
192 (0..extent)
193 .prop_flat_map(move |start| {
194 (1..=extent - start).prop_map(move |len| (start, len))
195 })
196 .boxed()
197 }
198 };
199
200 // Explicit match based on dims
201 match sizes.len() {
202 1 => (dim_strat(sizes[0]),).prop_map(|(a,)| vec![a]).boxed(),
203 2 => (dim_strat(sizes[0]), dim_strat(sizes[1]))
204 .prop_map(|(a, b)| vec![a, b])
205 .boxed(),
206 3 => (
207 dim_strat(sizes[0]),
208 dim_strat(sizes[1]),
209 dim_strat(sizes[2]),
210 )
211 .prop_map(|(a, b, c)| vec![a, b, c])
212 .boxed(),
213 4 => (
214 dim_strat(sizes[0]),
215 dim_strat(sizes[1]),
216 dim_strat(sizes[2]),
217 dim_strat(sizes[3]),
218 )
219 .prop_map(|(a, b, c, d)| vec![a, b, c, d])
220 .boxed(),
221 5 => (
222 dim_strat(sizes[0]),
223 dim_strat(sizes[1]),
224 dim_strat(sizes[2]),
225 dim_strat(sizes[3]),
226 dim_strat(sizes[4]),
227 )
228 .prop_map(|(a, b, c, d, e)| vec![a, b, c, d, e])
229 .boxed(),
230 6 => (
231 dim_strat(sizes[0]),
232 dim_strat(sizes[1]),
233 dim_strat(sizes[2]),
234 dim_strat(sizes[3]),
235 dim_strat(sizes[4]),
236 dim_strat(sizes[5]),
237 )
238 .prop_map(|(a, b, c, d, e, f)| vec![a, b, c, d, e, f])
239 .boxed(),
240 7 => (
241 dim_strat(sizes[0]),
242 dim_strat(sizes[1]),
243 dim_strat(sizes[2]),
244 dim_strat(sizes[3]),
245 dim_strat(sizes[4]),
246 dim_strat(sizes[5]),
247 dim_strat(sizes[6]),
248 )
249 .prop_map(|(a, b, c, d, e, f, g)| vec![a, b, c, d, e, f, g])
250 .boxed(),
251 8 => (
252 dim_strat(sizes[0]),
253 dim_strat(sizes[1]),
254 dim_strat(sizes[2]),
255 dim_strat(sizes[3]),
256 dim_strat(sizes[4]),
257 dim_strat(sizes[5]),
258 dim_strat(sizes[6]),
259 dim_strat(sizes[7]),
260 )
261 .prop_map(|(a, b, c, d, e, f, g, h)| vec![a, b, c, d, e, f, g, h])
262 .boxed(),
263 _ => unreachable!("max_dims constrained to 8"),
264 }
265 .prop_map(move |ranges| {
266 let (starts, lens): (Vec<_>, Vec<_>) = ranges.into_iter().unzip();
267 let subview = base.subview(&starts, &lens).expect("valid subview");
268 (base.clone(), subview)
269 })
270 })
271}
272
273/// Recursively generates a random `Selection` expression of bounded
274/// depth, aligned with the given slice `shape`.
275///
276/// Each recursive call corresponds to one dimension of the shape,
277/// starting from `dim`, and constructs a selection operator (`range`,
278/// `all`, `intersection`, etc.) that applies at that level.
279///
280/// The recursion proceeds until either:
281/// - `depth == 0`, which limits structural complexity, or
282/// - `dim >= shape.len()`, which prevents exceeding the
283/// dimensionality.
284///
285/// In both cases, the recursion terminates with a `true_()` leaf
286/// node, effectively selecting all remaining elements.
287///
288/// The resulting selections are guaranteed to be valid under a strict
289/// validation regime: they contain no empty ranges, no out-of-bounds
290/// accesses, and no dynamic constructs like `any` or `first`.
291pub fn gen_selection(depth: u32, shape: Vec<usize>, dim: usize) -> BoxedStrategy<Selection> {
292 let leaf = Just(dsl::true_()).boxed();
293
294 if depth == 0 || dim >= shape.len() {
295 return leaf;
296 }
297
298 let recur = {
299 let shape = shape.clone();
300 move || gen_selection(depth - 1, shape.clone(), dim + 1)
301 };
302
303 let range_strategy = {
304 let dim_size = shape[dim];
305 (0..dim_size)
306 .prop_flat_map(move |start| {
307 let max_len = dim_size - start;
308 (1..=max_len).prop_flat_map(move |len| {
309 (1..=len).prop_map(move |step| {
310 let r = Range(start, Some(start + len), step);
311 dsl::range(r, dsl::true_())
312 })
313 })
314 })
315 .boxed()
316 };
317
318 let all = recur().prop_map(dsl::all).boxed();
319
320 let union = (recur(), recur())
321 .prop_map(|(a, b)| dsl::union(a, b))
322 .boxed();
323
324 let inter = (recur(), recur())
325 .prop_map(|(a, b)| dsl::intersection(a, b))
326 .boxed();
327
328 prop_oneof![
329 2 => leaf,
330 3 => range_strategy,
331 3 => all,
332 2 => union,
333 2 => inter,
334 ]
335 .prop_filter("valid selection", move |s| {
336 let slice = Slice::new_row_major(shape.clone());
337 let eval_opts = EvalOpts {
338 disallow_dynamic_selections: true,
339 ..EvalOpts::strict()
340 };
341 s.validate(&eval_opts, &slice).is_ok()
342 })
343 .boxed()
344}
345
346#[cfg(test)]
347mod tests {
348 use std::collections::HashSet;
349
350 use proptest::strategy::ValueTree;
351 use proptest::test_runner::Config;
352 use proptest::test_runner::TestRunner;
353
354 use super::*;
355
356 #[test]
357 fn print_some_slices() {
358 let mut runner = TestRunner::new(Config::default());
359
360 for _ in 0..256 {
361 let strat = gen_slice(4, 8); // up to 4 dimensions, max size per dim = 8
362 let value = strat.new_tree(&mut runner).unwrap().current();
363 println!("{:?}", value);
364 }
365 }
366
367 proptest! {
368 #[test]
369 fn test_slice_properties(slice in gen_slice(4, 8)) {
370 let total_size: usize = slice.sizes().iter().product();
371 prop_assert!(total_size > 0);
372 }
373 }
374
375 #[test]
376 fn print_some_selections() {
377 let mut runner = TestRunner::new(Config::default());
378
379 for _ in 0..256 {
380 let strat = gen_selection(3, vec![2, 4, 8], 0);
381 let value = strat.new_tree(&mut runner).unwrap().current();
382 println!("{:?}", value);
383 }
384 }
385
386 // Theorem (Subview-Coordinate Inclusion):
387 //
388 // For any rectangular subview `V` of a base slice `B`, each
389 // coordinate `v` in `V` maps to a coordinate `b = view_offset +
390 // v` that must be in `B`.
391 //
392 // This test verifies that all coordinates of a generated subview
393 // are valid when translated back into the coordinate system of
394 // the base slice.
395 proptest! {
396 #[test]
397 fn test_gen_slice_and_subview((base, subview) in gen_slice_and_subview(4, 8)) {
398 for idx in subview.iter() {
399 let v = subview.coordinates(idx).unwrap();
400 let view_offset_in_base = base.coordinates(subview.offset()).unwrap();
401
402 // b = view_offset + v
403 let b: Vec<_> = v.iter()
404 .zip(&view_offset_in_base)
405 .map(|(sub_c, offset)| sub_c + offset)
406 .collect();
407
408 assert!(base.location(&b).is_ok());
409 }
410 }
411 }
412
413 // Coordinate–Rank Isomorphism for Extents
414
415 // Theorem 1: Rank is injective on valid points
416 //
417 // For a given Extent, every distinct coordinate (i.e. Point)
418 // maps to a unique rank.
419 //
420 // ∀ p ≠ q ∈ extent.iter(), p.rank() ≠ q.rank()
421 proptest! {
422 #[test]
423 fn rank_is_injective(extent in gen_extent(1..=4, 8)) {
424 let mut seen = HashSet::new();
425 for point in extent.points() {
426 let rank = point.rank();
427 prop_assert!(
428 seen.insert(rank),
429 "Duplicate rank {} for point {}",
430 rank,
431 point
432 );
433 }
434 }
435 }
436
437 // Theorem 2: Row-major monotonicity
438 //
439 // The rank function is monotonic in lexicographic (row-major)
440 // coordinate order.
441 //
442 // ∀ p, q ∈ ℕᵈ, p ≺ q ⇒ rank(p) < rank(q)
443 proptest! {
444 #[test]
445 fn rank_is_monotonic(extent in gen_extent(1..=4, 8)) {
446 let mut last_rank = None;
447 for point in extent.points() {
448 let rank = point.rank();
449 if let Some(prev) = last_rank {
450 prop_assert!(prev < rank, "Rank not monotonic: {} >= {}", prev, rank);
451 }
452 last_rank = Some(rank);
453 }
454 }
455 }
456
457 // Theorem 3: Rank bounds
458 //
459 // For any point p in extent E, the rank of p is in the range:
460 // 0 ≤ rank(p) < E.len()
461 //
462 // ∀ p ∈ E, 0 ≤ rank(p) < |E|
463 proptest! {
464 #[test]
465 fn rank_bounds(extent in gen_extent(1..=4, 8)) {
466 let len = extent.num_ranks();
467 for point in extent.points() {
468 let rank = point.rank();
469 prop_assert!(rank < len, "Rank {} out of bounds for extent of size {}", rank, len);
470 }
471 }
472 }
473
474 // Theorem 4: Isomorphism (Rank-point round-trip is identity on
475 // all ranks)
476 //
477 // For every valid rank r ∈ [0, extent.len()), converting it to a
478 // point and back gives the same rank:
479 //
480 // rank(point_of_rank(r)) = r
481 //
482 // In categorical terms: rank ∘ point_of_rank = 𝟙
483 proptest! {
484 #[test]
485 fn rank_point_trip(extent in gen_extent(1..=4, 8)) {
486 for r in 0..extent.num_ranks() {
487 let point = extent.point_of_rank(r).unwrap();
488 prop_assert_eq!(
489 point.rank(),
490 r,
491 "point_of_rank({}) returned {}, which maps to rank {}",
492 r,
493 point,
494 point.rank()
495 );
496 }
497 }
498 }
499
500 // Theorem 5: Isomorphism (Point–Coords–Rank round-trip is
501 // identity on all points)
502 //
503 // For every point p ∈ extent.points(), converting its coordinates
504 // back to a rank yields the same rank:
505 //
506 // rank_of_coords(coords(p)) = rank(p)
507 //
508 // In categorical terms: rank_of_coords ∘ coords = rank
509 proptest! {
510 #[test]
511 fn coords_to_rank_roundtrip(extent in gen_extent(0..=4, 8)) {
512 for p in extent.points() {
513 let c = p.coords();
514 let r = extent.rank_of_coords(&c).expect("coords from Point must be valid");
515 prop_assert_eq!(r, p.rank(), "coords->rank mismatch for {}", p);
516 }
517 }
518 }
519}