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