Skip to main content

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}