polydat_grammar/comprehension/strategy.rs
1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Strategy taxonomy and zip modes — spec §3.6 + §3.3.
5//!
6//! `StrategyName` is a closed enum per spec §10.7.5: adding a
7//! new strategy is a coordinated type extension (parser
8//! keyword, §3.6 table row, §10.2 R2 push-down rule). No
9//! user-defined `Custom` callback escape hatch — the spec
10//! removed it in favor of named strategies whose closed-form
11//! semantics the optimizer can analyze.
12
13use serde::{Deserialize, Serialize};
14
15/// Named ordering strategies per spec §3.6 (plus `Shuffle`).
16///
17/// Each strategy declares its accepted input `IndexFn` shape
18/// per the spec §3.6 strategy table. V4 (§5) enforces the
19/// per-strategy input-shape contract; R2 (§10.2) implements
20/// the per-strategy push-down rule.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
22pub enum StrategyName {
23 /// Natural enumeration order — pass-through. Accepts any
24 /// input including `None`. The only strategy whose
25 /// materialization is `Streaming` (§10.2 R1).
26 Lex,
27
28 /// Reverse the input's index range. Accepts any non-`None`
29 /// discrete IndexFn; rejected over continuous.
30 ReverseLex,
31
32 /// Random permutation. PRNG seed captured at
33 /// materialization. Accepts any non-`None` IndexFn
34 /// including continuous.
35 Shuffle,
36
37 /// Halton low-discrepancy sequence. K-D over Lattice;
38 /// 1-D over single-axis index spaces; native to
39 /// Continuous (canonical use case).
40 Halton,
41
42 /// Sobol low-discrepancy sequence. Same shape as Halton.
43 Sobol,
44
45 /// Latin Hypercube stratified sampling. K-D over Lattice;
46 /// degenerate (= Shuffle) over 1-D; native to Continuous.
47 Lhs,
48
49 /// K-D lattice corners (2^N). Sorted by distance metric;
50 /// emit top k. Discrete Lattice with N≥2 axes or
51 /// Continuous box; degenerate over 1-D.
52 Extrema,
53
54 /// Concentric shell partitioning. Discrete only; rejected
55 /// over continuous (ill-defined without discretization).
56 Shells,
57
58 /// Index-sum-ascending walk. Discrete Lattice with N≥2
59 /// axes; rejected over continuous.
60 Diagonal,
61
62 /// Index-sum-descending walk. Discrete Lattice with N≥2
63 /// axes; rejected over continuous.
64 Antidiagonal,
65}
66
67impl StrategyName {
68 /// `true` if the strategy is `Lex` — the only streaming
69 /// strategy (§3.6, §6.2, §10.2 R1).
70 pub fn is_streaming(self) -> bool {
71 matches!(self, StrategyName::Lex)
72 }
73
74 /// `true` if the strategy operates over a 1-D index space
75 /// without geometric interpretation. Index-sampling
76 /// strategies (Halton/Sobol/Lhs/Shuffle/ReverseLex) work
77 /// over any non-`None` IndexFn; lattice-geometric
78 /// strategies (Extrema/Shells/Diagonal/Antidiagonal)
79 /// require Lattice with ≥2 axes.
80 pub fn is_index_sampling(self) -> bool {
81 matches!(
82 self,
83 StrategyName::Halton
84 | StrategyName::Sobol
85 | StrategyName::Lhs
86 | StrategyName::Shuffle
87 | StrategyName::ReverseLex
88 )
89 }
90
91 /// `true` if the strategy is a lattice-geometric measure
92 /// (corners, shells, diagonals). These require a multi-axis
93 /// Lattice and reject continuous inputs.
94 pub fn is_lattice_geometric(self) -> bool {
95 matches!(
96 self,
97 StrategyName::Extrema
98 | StrategyName::Shells
99 | StrategyName::Diagonal
100 | StrategyName::Antidiagonal
101 )
102 }
103
104 /// Human-readable strategy name used in surface syntax
105 /// (e.g., `order halton/n`).
106 pub fn as_str(self) -> &'static str {
107 match self {
108 StrategyName::Lex => "lex",
109 StrategyName::ReverseLex => "reverse_lex",
110 StrategyName::Shuffle => "shuffle",
111 StrategyName::Halton => "halton",
112 StrategyName::Sobol => "sobol",
113 StrategyName::Lhs => "lhs",
114 StrategyName::Extrema => "extrema",
115 StrategyName::Shells => "shells",
116 StrategyName::Diagonal => "diagonal",
117 StrategyName::Antidiagonal => "antidiagonal",
118 }
119 }
120}
121
122impl std::fmt::Display for StrategyName {
123 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124 f.write_str(self.as_str())
125 }
126}
127
128/// Zip combination mode per spec §3.3.
129///
130/// - `Strict` errors on length mismatch (V7).
131/// - `Truncate` cuts to the shortest child.
132/// - `Cycle` repeats shorter children to the longest's length;
133/// permits one Unbounded child (the longest) with the others
134/// Bounded.
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
136pub enum ZipMode {
137 /// A length mismatch is an error.
138 Strict,
139 /// Cut to the shortest child.
140 Truncate,
141 /// Repeat shorter children to the longest's length.
142 Cycle,
143}
144
145impl ZipMode {
146 /// The mode's name, as the comprehension grammar spells it.
147 pub fn as_str(self) -> &'static str {
148 match self {
149 ZipMode::Strict => "strict",
150 ZipMode::Truncate => "truncate",
151 ZipMode::Cycle => "cycle",
152 }
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 #[test]
161 fn lex_is_the_only_streaming_strategy() {
162 assert!(StrategyName::Lex.is_streaming());
163 for s in [
164 StrategyName::ReverseLex,
165 StrategyName::Shuffle,
166 StrategyName::Halton,
167 StrategyName::Sobol,
168 StrategyName::Lhs,
169 StrategyName::Extrema,
170 StrategyName::Shells,
171 StrategyName::Diagonal,
172 StrategyName::Antidiagonal,
173 ] {
174 assert!(!s.is_streaming(), "{s:?} should not be streaming");
175 }
176 }
177
178 #[test]
179 fn index_sampling_strategies_classified_correctly() {
180 for s in [
181 StrategyName::Halton,
182 StrategyName::Sobol,
183 StrategyName::Lhs,
184 StrategyName::Shuffle,
185 StrategyName::ReverseLex,
186 ] {
187 assert!(s.is_index_sampling(), "{s:?} should be index-sampling");
188 }
189 }
190
191 #[test]
192 fn lattice_geometric_strategies_classified_correctly() {
193 for s in [
194 StrategyName::Extrema,
195 StrategyName::Shells,
196 StrategyName::Diagonal,
197 StrategyName::Antidiagonal,
198 ] {
199 assert!(
200 s.is_lattice_geometric(),
201 "{s:?} should be lattice-geometric"
202 );
203 }
204 }
205
206 #[test]
207 fn strategy_classes_are_disjoint() {
208 // Lex is neither index-sampling nor lattice-geometric;
209 // every other strategy is in exactly one of the two
210 // classes.
211 for s in [
212 StrategyName::ReverseLex,
213 StrategyName::Shuffle,
214 StrategyName::Halton,
215 StrategyName::Sobol,
216 StrategyName::Lhs,
217 StrategyName::Extrema,
218 StrategyName::Shells,
219 StrategyName::Diagonal,
220 StrategyName::Antidiagonal,
221 ] {
222 let sampling = s.is_index_sampling();
223 let geometric = s.is_lattice_geometric();
224 assert!(sampling ^ geometric, "{s:?} must be in exactly one class");
225 }
226 }
227
228 #[test]
229 fn strategy_string_round_trip() {
230 let s = StrategyName::Halton;
231 let json = serde_json::to_string(&s).unwrap();
232 let back: StrategyName = serde_json::from_str(&json).unwrap();
233 assert_eq!(s, back);
234 assert_eq!(s.as_str(), "halton");
235 }
236}