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