Skip to main content

polydat_core/iteration/comprehension/predicate/
info.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! `PredicateInfo` and supporting enums — comprehension_forms.md
5//! §10.9.3.
6//!
7//! Five independent assertions the analyzer makes about a
8//! predicate:
9//!
10//! - `factorization` — how the predicate decomposes by
11//!   coordinate.
12//! - `monotonicity` — per-axis direction of truth.
13//! - `range_constraint` — per-axis value-bound implications.
14//! - `determinism` — whether the predicate is referentially
15//!   transparent.
16//! - `coords_referenced` — the set of `{name}` references in
17//!   the predicate text.
18//!
19//! All fields are independent — a predicate may have rich
20//! factorization but no monotonicity, etc.
21
22use serde::{Deserialize, Serialize};
23
24/// Structured analysis output for one predicate.
25///
26/// Construction is the analyzer's responsibility; consumers
27/// (R5 et al.) only read.
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29pub struct PredicateInfo {
30    /// How the predicate decomposes by coordinate.
31    pub factorization: Factorization,
32
33    /// Per-axis monotonicity assertions. Missing axes have no
34    /// monotonicity claim.
35    pub monotonicity: PerAxisMap<Monotonicity>,
36
37    /// Per-axis range constraint implied by the predicate.
38    /// Missing axes have no constraint claim.
39    pub range_constraint: PerAxisMap<RangeConstraint>,
40
41    /// Whether the predicate is referentially transparent.
42    pub determinism: Determinism,
43
44    /// Names referenced by `{name}` interpolations in the
45    /// predicate text. Coords NOT in the wrapped
46    /// comprehension's coordinate set may still appear here
47    /// (parent-scope references — the link-time consumer
48    /// handles them per V3).
49    pub coords_referenced: Vec<String>,
50}
51
52/// Per-coordinate decomposition of a predicate.
53///
54/// comprehension_forms.md §10.9.3 defines the four variants.
55/// `Opaque` carries an [`OpaqueReason`] explaining why the analyzer
56/// couldn't decompose further. R5 fires only on `PerAxis`.
57#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58#[serde(tag = "kind", rename_all = "snake_case")]
59pub enum Factorization {
60    /// Predicate factorizes per-axis: `p ≡ p_a({a}) && p_b({b}) && …`
61    /// Each entry is the per-axis sub-predicate (as a string,
62    /// matching the input predicate's lexical form).
63    PerAxis(PerAxisMap<String>),
64
65    /// Conjunction of sub-predicates where each may still
66    /// cross-cut multiple axes.
67    Conjunctive(Vec<String>),
68
69    /// Disjunction of sub-predicates, each `PerAxis` or
70    /// `Conjunctive` itself (§10.9.5's disjunction rule).
71    Disjunctive(Vec<String>),
72
73    /// Analyzer can't structurally decompose. R5 doesn't fire.
74    Opaque(OpaqueReason),
75}
76
77/// Why the analyzer marked a predicate `Opaque`
78/// (comprehension_forms.md §10.9.3).
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(rename_all = "snake_case")]
81pub enum OpaqueReason {
82    /// Predicate shape isn't in the §10.9.5 recognizer
83    /// catalog.
84    UnknownPattern,
85
86    /// Predicate references a non-deterministic Polydat function
87    /// (PRNG draw, time-of-day, etc.). Detected
88    /// conservatively — any function call we don't recognize
89    /// as deterministic falls here.
90    NonDeterministic,
91
92    /// Predicate depends on previously-emitted tuples. No predicate
93    /// the language can write does; the variant names the reason
94    /// for completeness.
95    CrossTupleState,
96
97    /// Predicate has observable side effects.
98    SideEffecting,
99
100    /// Predicate references one or more continuous-cardinality
101    /// coordinates, which the analyzer treats as opaque
102    /// (comprehension_forms.md §14.2).
103    Continuous,
104}
105
106/// Per-axis monotonicity direction. "Increasing" means: once
107/// the predicate becomes true for `axis = k`, it stays true
108/// for all `axis ≥ k`. It is the fact R10 (monotonic-cutoff
109/// truncation, outside the catalog per §14.1) would read.
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(rename_all = "snake_case")]
112pub enum Monotonicity {
113    /// Once true at an axis value, true for every greater value.
114    Increasing,
115    /// Once true at an axis value, true for every lesser value.
116    Decreasing,
117    /// No monotonicity established.
118    None,
119}
120
121/// Per-axis value-bound implied by the predicate: the fact R8
122/// (range-narrowing) and R9 (discrete-set substitution), outside the
123/// catalog per comprehension_forms.md §14.1, would read.
124#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
125#[serde(tag = "kind", rename_all = "snake_case")]
126pub enum RangeConstraint {
127    /// `lo ≤ axis ≤ hi` (with open/closed flags).
128    Bounded {
129        /// The lower bound, if any.
130        lo: Option<ConstValue>,
131        /// The upper bound, if any.
132        hi: Option<ConstValue>,
133        /// Whether the lower bound is included.
134        lo_inclusive: bool,
135        /// Whether the upper bound is included.
136        hi_inclusive: bool,
137    },
138    /// `axis ∈ {v_1, v_2, …}` (e.g., from an `in` predicate).
139    Discrete(Vec<ConstValue>),
140    /// No constraint asserted for this axis.
141    None,
142}
143
144/// Whether a predicate is referentially transparent. Same
145/// `(predicate, coords)` always produces the same boolean.
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
147#[serde(rename_all = "snake_case")]
148pub enum Determinism {
149    /// The same predicate and coordinates always give the same answer.
150    Deterministic,
151    /// The predicate may depend on something else.
152    Opaque,
153}
154
155/// Constant value used inside `RangeConstraint::Bounded` / `Discrete`.
156/// Subset of polydat's `Value` sufficient for the initial
157/// recognizer catalog.
158#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
159#[serde(untagged)]
160pub enum ConstValue {
161    /// An integer.
162    Int(i64),
163    /// A float.
164    Float(f64),
165    /// A string.
166    String(String),
167    /// A boolean.
168    Bool(bool),
169}
170
171/// Map keyed by coordinate name. Insertion order preserves
172/// declaration order so downstream consumers can iterate
173/// per-axis in the comprehension's tuple-shape order.
174#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
175pub struct PerAxisMap<T> {
176    entries: Vec<(String, T)>,
177}
178
179impl<T> PerAxisMap<T> {
180    /// An empty map.
181    pub fn new() -> Self {
182        Self {
183            entries: Vec::new(),
184        }
185    }
186
187    /// Set the entry for a coordinate, keeping its position if present.
188    pub fn insert<K: Into<String>>(&mut self, key: K, value: T) {
189        let key = key.into();
190        // Replace if already present; preserves position.
191        if let Some(slot) = self.entries.iter_mut().find(|(k, _)| *k == key) {
192            slot.1 = value;
193        } else {
194            self.entries.push((key, value));
195        }
196    }
197
198    /// The entry for a coordinate, if any.
199    pub fn get(&self, key: &str) -> Option<&T> {
200        self.entries.iter().find(|(k, _)| k == key).map(|(_, v)| v)
201    }
202
203    /// The entries, in declaration order.
204    pub fn iter(&self) -> impl Iterator<Item = (&str, &T)> {
205        self.entries.iter().map(|(k, v)| (k.as_str(), v))
206    }
207
208    /// The number of entries.
209    pub fn len(&self) -> usize {
210        self.entries.len()
211    }
212
213    /// Whether the map has no entry.
214    pub fn is_empty(&self) -> bool {
215        self.entries.is_empty()
216    }
217
218    /// The coordinate names, in declaration order.
219    pub fn keys(&self) -> impl Iterator<Item = &str> {
220        self.entries.iter().map(|(k, _)| k.as_str())
221    }
222}
223
224impl<T> Default for PerAxisMap<T> {
225    fn default() -> Self {
226        Self::new()
227    }
228}
229
230impl<T> FromIterator<(String, T)> for PerAxisMap<T> {
231    fn from_iter<I: IntoIterator<Item = (String, T)>>(iter: I) -> Self {
232        let mut m = Self::new();
233        for (k, v) in iter {
234            m.insert(k, v);
235        }
236        m
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn per_axis_map_insertion_order() {
246        let mut m: PerAxisMap<i64> = PerAxisMap::new();
247        m.insert("k", 10);
248        m.insert("limit", 20);
249        let keys: Vec<&str> = m.keys().collect();
250        assert_eq!(keys, vec!["k", "limit"]);
251    }
252
253    #[test]
254    fn per_axis_map_replace_preserves_position() {
255        let mut m: PerAxisMap<i64> = PerAxisMap::new();
256        m.insert("k", 10);
257        m.insert("limit", 20);
258        m.insert("k", 100); // replace
259        let keys: Vec<&str> = m.keys().collect();
260        assert_eq!(keys, vec!["k", "limit"]);
261        assert_eq!(*m.get("k").unwrap(), 100);
262    }
263
264    #[test]
265    fn predicate_info_round_trip_serde() {
266        let info = PredicateInfo {
267            factorization: Factorization::PerAxis(
268                vec![
269                    ("k".to_string(), "{k} > 0".to_string()),
270                    ("limit".to_string(), "{limit} < 100".to_string()),
271                ]
272                .into_iter()
273                .collect(),
274            ),
275            monotonicity: PerAxisMap::new(),
276            range_constraint: PerAxisMap::new(),
277            determinism: Determinism::Deterministic,
278            coords_referenced: vec!["k".to_string(), "limit".to_string()],
279        };
280        let json = serde_json::to_string(&info).unwrap();
281        let back: PredicateInfo = serde_json::from_str(&json).unwrap();
282        assert_eq!(info, back);
283    }
284
285    #[test]
286    fn opaque_reason_serde() {
287        let info = PredicateInfo {
288            factorization: Factorization::Opaque(OpaqueReason::Continuous),
289            monotonicity: PerAxisMap::new(),
290            range_constraint: PerAxisMap::new(),
291            determinism: Determinism::Deterministic,
292            coords_referenced: vec!["theta".to_string()],
293        };
294        let json = serde_json::to_string(&info).unwrap();
295        assert!(json.contains("continuous"));
296        let back: PredicateInfo = serde_json::from_str(&json).unwrap();
297        assert_eq!(info, back);
298    }
299}