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}