Skip to main content

polydat_core/iteration/comprehension/predicate/
coordset.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Coordinate set with per-coord classification — spec §10.9.2.
5//!
6//! The predicate analyzer takes a `CoordSet` (not a bare list
7//! of names) so it can detect continuous-coord references and
8//! mark them `Opaque(Continuous)`. The set's per-coord kind
9//! is supplied by the caller — typically derived from the
10//! wrapped comprehension's metadata (`Metadata::index_addressable`
11//! variant + cardinality classification).
12
13use serde::{Deserialize, Serialize};
14
15use crate::iteration::comprehension::metadata::{IndexFn, Metadata};
16
17/// A coordinate's name plus its discrete/continuous
18/// classification.
19#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
20pub struct CoordInfo {
21    /// The coordinate's name.
22    pub name: String,
23    /// Discrete or continuous.
24    pub kind: CoordKind,
25}
26
27/// Coordinate cardinality classification used by the
28/// predicate analyzer. Mirrors the discrete-vs-continuous
29/// split that drives `OpaqueReason::Continuous`.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum CoordKind {
33    /// Enumerable values.
34    Discrete,
35    /// A real interval, sampled.
36    Continuous,
37}
38
39/// Coordinate-name set with per-coord classification.
40/// Preserves declaration order so the analyzer's output (and
41/// downstream `R5`) sees axes in the same order they appear in
42/// the wrapped comprehension's tuple shape.
43#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
44pub struct CoordSet {
45    coords: Vec<CoordInfo>,
46}
47
48impl CoordSet {
49    /// An empty set.
50    pub fn new() -> Self {
51        Self { coords: Vec::new() }
52    }
53
54    /// Add a coordinate at the end.
55    pub fn push(&mut self, info: CoordInfo) {
56        self.coords.push(info);
57    }
58
59    /// All-discrete coord set from a name list. Convenience
60    /// for tests and discrete-only call sites.
61    pub fn all_discrete<I, S>(names: I) -> Self
62    where
63        I: IntoIterator<Item = S>,
64        S: Into<String>,
65    {
66        Self {
67            coords: names
68                .into_iter()
69                .map(|n| CoordInfo {
70                    name: n.into(),
71                    kind: CoordKind::Discrete,
72                })
73                .collect(),
74        }
75    }
76
77    /// Build a `CoordSet` from a comprehension's coordinate
78    /// names and its propagated metadata. The metadata's
79    /// `index_addressable` variant determines whether each
80    /// axis is discrete or continuous.
81    ///
82    /// For comprehensions with `None` `index_addressable`
83    /// (raw filter output, dependent cartesian), every axis is
84    /// classified as discrete — the conservative choice that
85    /// keeps the analyzer running. Continuous classification
86    /// requires a `Continuous` or `Hybrid` `IndexFn`, where
87    /// the per-axis split is unambiguous.
88    pub fn from_metadata(coord_names: &[String], metadata: &Metadata) -> Self {
89        let kinds = classify_axes(metadata.index_addressable.as_ref(), coord_names.len());
90        let coords = coord_names
91            .iter()
92            .zip(kinds)
93            .map(|(name, kind)| CoordInfo {
94                name: name.clone(),
95                kind,
96            })
97            .collect();
98        Self { coords }
99    }
100
101    /// The coordinates, in declaration order.
102    pub fn iter(&self) -> impl Iterator<Item = &CoordInfo> {
103        self.coords.iter()
104    }
105
106    /// The names, in declaration order.
107    pub fn names(&self) -> impl Iterator<Item = &str> {
108        self.coords.iter().map(|c| c.name.as_str())
109    }
110
111    /// The coordinate named, if any.
112    pub fn get(&self, name: &str) -> Option<&CoordInfo> {
113        self.coords.iter().find(|c| c.name == name)
114    }
115
116    /// Whether the named coordinate is continuous.
117    pub fn is_continuous(&self, name: &str) -> bool {
118        matches!(self.get(name).map(|c| c.kind), Some(CoordKind::Continuous))
119    }
120
121    /// Whether the set has the named coordinate.
122    pub fn contains(&self, name: &str) -> bool {
123        self.get(name).is_some()
124    }
125
126    /// The number of coordinates.
127    pub fn len(&self) -> usize {
128        self.coords.len()
129    }
130
131    /// Whether the set has no coordinate.
132    pub fn is_empty(&self) -> bool {
133        self.coords.is_empty()
134    }
135}
136
137impl Default for CoordSet {
138    fn default() -> Self {
139        Self::new()
140    }
141}
142
143/// Per-axis classification given the input's `IndexFn`.
144fn classify_axes(idx: Option<&IndexFn>, expected_count: usize) -> Vec<CoordKind> {
145    match idx {
146        None => vec![CoordKind::Discrete; expected_count],
147        Some(IndexFn::Lattice { axis_sizes }) => {
148            vec![CoordKind::Discrete; axis_sizes.len()]
149        }
150        Some(IndexFn::Lockstep { .. }) | Some(IndexFn::Modular { .. }) => {
151            vec![CoordKind::Discrete; expected_count]
152        }
153        Some(IndexFn::Concatenation { .. }) => {
154            vec![CoordKind::Discrete; expected_count]
155        }
156        Some(IndexFn::Continuous { intervals, .. }) => {
157            vec![CoordKind::Continuous; intervals.len()]
158        }
159        Some(IndexFn::Hybrid {
160            discrete_axes,
161            continuous_axes,
162            ..
163        }) => {
164            let mut kinds = vec![CoordKind::Discrete; discrete_axes.len()];
165            kinds.extend(vec![CoordKind::Continuous; continuous_axes.len()]);
166            kinds
167        }
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use crate::iteration::comprehension::cardinality::{
175        CardinalityClass, Interval, ProductMeasure,
176    };
177    use crate::iteration::comprehension::metadata::{Materialization, NaturalOrder};
178
179    fn dummy_metadata(idx: Option<IndexFn>) -> Metadata {
180        Metadata {
181            cardinality: CardinalityClass::Bounded(0),
182            index_addressable: idx,
183            natural_order: NaturalOrder::Lex,
184            materialization: Materialization::Streaming,
185        }
186    }
187
188    #[test]
189    fn all_discrete_convenience() {
190        let s = CoordSet::all_discrete(["k", "limit"]);
191        assert_eq!(s.len(), 2);
192        assert!(!s.is_continuous("k"));
193        assert!(!s.is_continuous("limit"));
194        assert!(s.contains("k"));
195        assert!(!s.contains("missing"));
196    }
197
198    #[test]
199    fn from_metadata_lattice_all_discrete() {
200        let m = dummy_metadata(Some(IndexFn::Lattice {
201            axis_sizes: vec![3, 4],
202        }));
203        let s = CoordSet::from_metadata(&["k".to_string(), "limit".to_string()], &m);
204        assert_eq!(s.len(), 2);
205        assert!(!s.is_continuous("k"));
206        assert!(!s.is_continuous("limit"));
207    }
208
209    #[test]
210    fn from_metadata_continuous_all_continuous() {
211        let m = dummy_metadata(Some(IndexFn::Continuous {
212            intervals: vec![Interval::closed(0.0, 1.0), Interval::closed(0.0, 1.0)],
213            measure: ProductMeasure::Uniform,
214        }));
215        let s = CoordSet::from_metadata(&["alpha".to_string(), "beta".to_string()], &m);
216        assert!(s.is_continuous("alpha"));
217        assert!(s.is_continuous("beta"));
218    }
219
220    #[test]
221    fn from_metadata_hybrid_per_axis_split() {
222        let m = dummy_metadata(Some(IndexFn::Hybrid {
223            discrete_axes: vec![5],
224            continuous_axes: vec![Interval::closed(0.0, 1.0)],
225            measure: ProductMeasure::Uniform,
226        }));
227        // Order in CoordSet must match: discrete axes first,
228        // then continuous (matches metadata.rs's combine_cartesian_index_fn).
229        let s = CoordSet::from_metadata(&["k".to_string(), "theta".to_string()], &m);
230        assert!(!s.is_continuous("k"));
231        assert!(s.is_continuous("theta"));
232    }
233
234    #[test]
235    fn from_metadata_none_index_fn_all_discrete() {
236        let m = dummy_metadata(None);
237        let s = CoordSet::from_metadata(&["a".to_string(), "b".to_string()], &m);
238        // Conservative — no Continuous classification when we
239        // can't determine kinds.
240        assert!(!s.is_continuous("a"));
241        assert!(!s.is_continuous("b"));
242    }
243}