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}