Skip to main content

helm_schema_core/
predicate.rs

1use std::collections::BTreeSet;
2
3use crate::Guard;
4
5/// Typed Boolean formula recovered from template control flow.
6#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
7pub enum Predicate {
8    /// Formula that holds for every input.
9    True,
10    /// Formula that holds for no input.
11    False,
12    /// A control condition whose exact relation could not be lowered.
13    ///
14    /// The paths remain available for diagnostics and conservative attribution, but consumers
15    /// must not turn this marker into a narrowing schema condition.
16    Approximate {
17        /// Stable description of the expression shape that could not be lowered.
18        marker: String,
19        /// Values paths mentioned by the unlowerable expression.
20        paths: BTreeSet<String>,
21        /// Guards whose conjunction IMPLIES the real condition (a sound
22        /// subset). Usable only in POSITIVE polarity where firing less
23        /// often is safe — a fail-arm's outer condition — never through a
24        /// negation, which would invert the containment. Empty when no
25        /// bounded strengthening was recognized.
26        sound_subset: Vec<Guard>,
27    },
28    /// Exactly lowerable atomic guard.
29    Guard(Guard),
30    /// Logical negation of a predicate.
31    Not(Box<Predicate>),
32    /// Conjunction of every enclosed predicate.
33    And(Vec<Predicate>),
34    /// Disjunction of the enclosed predicates.
35    Or(Vec<Predicate>),
36}
37
38impl From<Guard> for Predicate {
39    fn from(guard: Guard) -> Self {
40        match guard {
41            Guard::Not { path } => Self::Not(Box::new(Self::truthy_path(path))),
42            Guard::Or { paths } => Self::Or(paths.into_iter().map(Self::truthy_path).collect()),
43            Guard::AnyOf { alternatives } => Self::Or(
44                alternatives
45                    .into_iter()
46                    .map(|alternative| Self::all(alternative.into_iter().map(Self::from).collect()))
47                    .collect(),
48            ),
49            Guard::NotTypeIs { path, schema_type } => {
50                Self::Not(Box::new(Self::Guard(Guard::TypeIs { path, schema_type })))
51            }
52            guard => Self::Guard(guard),
53        }
54    }
55}
56
57impl Predicate {
58    /// Creates an atomic truthiness predicate for a values path.
59    pub fn truthy_path(path: impl Into<String>) -> Self {
60        Self::Guard(Guard::Truthy { path: path.into() })
61    }
62
63    /// Marks an unlowerable condition without inventing a relation between its paths.
64    pub fn approximate(marker: impl Into<String>, paths: BTreeSet<String>) -> Self {
65        Self::Approximate {
66            marker: marker.into(),
67            paths,
68            sound_subset: Vec::new(),
69        }
70    }
71
72    /// Marks an unlowerable condition that still admits a bounded sound
73    /// strengthening: `guards` hold only in states where the real condition
74    /// holds too.
75    pub fn approximate_with_sound_subset(
76        marker: impl Into<String>,
77        paths: BTreeSet<String>,
78        sound_subset: Vec<Guard>,
79    ) -> Self {
80        Self::Approximate {
81            marker: marker.into(),
82            paths,
83            sound_subset,
84        }
85    }
86
87    /// Normalizes a conjunction, collapsing empty and singleton formulas.
88    #[must_use]
89    pub fn all(predicates: Vec<Self>) -> Self {
90        match predicates.as_slice() {
91            [] => Self::True,
92            [predicate] => predicate.clone(),
93            _ => Self::And(predicates),
94        }
95    }
96
97    /// Returns the logical complement without retaining redundant double negation.
98    #[must_use]
99    pub fn negated(&self) -> Self {
100        match self {
101            Self::True => Self::False,
102            Self::False => Self::True,
103            Self::Not(inner) => inner.as_ref().clone(),
104            other => Self::Not(Box::new(other.clone())),
105        }
106    }
107
108    /// Reports whether the predicate is the constant `true` or `false` formula.
109    #[must_use]
110    pub fn is_trivial(&self) -> bool {
111        matches!(self, Self::True | Self::False)
112    }
113
114    /// Whether this predicate contains a condition that could not be lowered exactly.
115    /// Returns every values path referenced by the formula.
116    #[must_use]
117    pub fn contains_approximation(&self) -> bool {
118        match self {
119            Self::Approximate { .. } => true,
120            Self::Not(inner) => inner.contains_approximation(),
121            Self::And(predicates) | Self::Or(predicates) => {
122                predicates.iter().any(Self::contains_approximation)
123            }
124            Self::True | Self::False | Self::Guard(_) => false,
125        }
126    }
127
128    /// Returns every values path referenced by the formula.
129    #[must_use]
130    pub fn value_paths(&self) -> BTreeSet<String> {
131        let mut paths = BTreeSet::new();
132        self.collect_value_paths(&mut paths);
133        paths
134    }
135
136    /// Expands header predicates into the context-selection facts active in their bodies.
137    pub fn with_context_predicates(self) -> Vec<Self> {
138        match self {
139            Self::True => Vec::new(),
140            Self::False => vec![Self::False],
141            Self::Approximate { .. }
142            | Self::Guard(
143                Guard::Range { .. }
144                | Guard::RangeKeyPrefix { .. }
145                | Guard::RangeKeyEquals { .. }
146                | Guard::RangeKeyMatches { .. }
147                | Guard::Absent { .. }
148                | Guard::With { .. }
149                | Guard::Default { .. }
150                | Guard::TypeIs { .. }
151                | Guard::NotTypeIs { .. }
152                | Guard::Not { .. }
153                | Guard::Or { .. }
154                | Guard::AnyOf { .. }
155                | Guard::IntGt { .. }
156                | Guard::IntLt { .. }
157                | Guard::AtMostOneMember { .. }
158                | Guard::MinMembers { .. }
159                | Guard::HasKey { .. }
160                | Guard::ContainsEquals { .. },
161            ) => vec![self],
162            Self::And(predicates) => predicates
163                .into_iter()
164                .flat_map(Self::with_context_predicates)
165                .collect(),
166            Self::Guard(Guard::Truthy { path }) => vec![Self::from(Guard::With { path })],
167            Self::Or(predicates) => {
168                let paths: Option<Vec<String>> = predicates
169                    .iter()
170                    .map(|predicate| match predicate {
171                        Self::Guard(Guard::Truthy { path }) => Some(path.clone()),
172                        _ => None,
173                    })
174                    .collect();
175                let Some(paths) = paths else {
176                    return vec![Self::Or(predicates)];
177                };
178                let mut out: Vec<Self> = paths
179                    .iter()
180                    .map(|path| Self::from(Guard::With { path: path.clone() }))
181                    .collect();
182                out.push(Self::Or(paths.into_iter().map(Self::truthy_path).collect()));
183                out
184            }
185            Self::Not(inner) => match inner.as_ref() {
186                Self::Guard(Guard::Truthy { path }) => vec![
187                    Self::from(Guard::With { path: path.clone() }),
188                    Self::Not(inner),
189                ],
190                _ => vec![Self::Not(inner)],
191            },
192            Self::Guard(Guard::Eq { path, value }) => vec![
193                Self::from(Guard::With { path: path.clone() }),
194                Self::from(Guard::Eq { path, value }),
195            ],
196            Self::Guard(Guard::MatchesPattern {
197                path,
198                pattern,
199                templated,
200            }) => vec![
201                Self::from(Guard::With { path: path.clone() }),
202                Self::from(Guard::MatchesPattern {
203                    path,
204                    pattern,
205                    templated,
206                }),
207            ],
208            Self::Guard(Guard::NotEq { path, value }) => vec![
209                Self::from(Guard::With { path: path.clone() }),
210                Self::from(Guard::NotEq { path, value }),
211            ],
212        }
213    }
214
215    /// Returns values paths whose branch structure permits them to be absent.
216    #[must_use]
217    pub fn conditionally_optional_paths(&self) -> BTreeSet<String> {
218        let mut paths = BTreeSet::new();
219        self.collect_conditionally_optional_paths(&mut paths);
220        paths
221    }
222
223    /// Projects this formula into the contract guard vocabulary.
224    pub fn contract_guards(&self) -> Vec<Guard> {
225        match self {
226            Self::True | Self::False | Self::Approximate { .. } => Vec::new(),
227            Self::Guard(guard) => vec![guard.clone()],
228            Self::Not(inner) => negated_contract_guards(inner),
229            Self::And(predicates) => predicates.iter().flat_map(Self::contract_guards).collect(),
230            Self::Or(predicates) => or_contract_guards(predicates),
231        }
232    }
233
234    /// Whether [`Self::contract_guards`] represents this predicate
235    /// EXACTLY: the flattened guard conjunction selects the same states.
236    /// Negations distribute by De Morgan down to negatable guard leaves;
237    /// a negation reaching a leaf the vocabulary cannot flip flattens to
238    /// NOTHING, which an `And` flatten would silently drop — a fail
239    /// conjunction missing such a conjunct negates into states the
240    /// validator never rejects, so callers keep those conjuncts as raw
241    /// predicates instead.
242    #[must_use]
243    pub fn contract_guards_are_exact(&self) -> bool {
244        match self {
245            Self::True | Self::Guard(_) => true,
246            Self::False | Self::Approximate { .. } => false,
247            Self::Not(inner) => negation_flattens_exactly(inner),
248            Self::And(predicates) | Self::Or(predicates) => {
249                predicates.iter().all(Self::contract_guards_are_exact)
250            }
251        }
252    }
253
254    fn collect_value_paths(&self, out: &mut BTreeSet<String>) {
255        match self {
256            Self::True | Self::False => {}
257            Self::Approximate { paths, .. } => out.extend(paths.iter().cloned()),
258            Self::Guard(guard) => {
259                for path in guard.value_paths() {
260                    out.insert(path.to_string());
261                }
262            }
263            Self::Not(inner) => inner.collect_value_paths(out),
264            Self::And(predicates) | Self::Or(predicates) => {
265                for predicate in predicates {
266                    predicate.collect_value_paths(out);
267                }
268            }
269        }
270    }
271
272    fn collect_conditionally_optional_paths(&self, out: &mut BTreeSet<String>) {
273        match self {
274            Self::Guard(Guard::NotEq { path, .. } | Guard::Absent { path }) => {
275                out.insert(path.clone());
276            }
277            Self::Not(inner) => match inner.as_ref() {
278                Self::Guard(Guard::Truthy { path }) => {
279                    out.insert(path.clone());
280                }
281                _ => inner.collect_conditionally_optional_paths(out),
282            },
283            Self::Or(predicates) => {
284                for predicate in predicates {
285                    out.extend(predicate.value_paths());
286                }
287            }
288            Self::And(predicates) => {
289                for predicate in predicates {
290                    predicate.collect_conditionally_optional_paths(out);
291                }
292            }
293            Self::True
294            | Self::False
295            | Self::Approximate { .. }
296            | Self::Guard(
297                Guard::Truthy { .. }
298                | Guard::Eq { .. }
299                | Guard::MatchesPattern { .. }
300                | Guard::RangeKeyPrefix { .. }
301                | Guard::RangeKeyEquals { .. }
302                | Guard::RangeKeyMatches { .. }
303                | Guard::Range { .. }
304                | Guard::With { .. }
305                | Guard::Default { .. }
306                | Guard::TypeIs { .. }
307                | Guard::NotTypeIs { .. }
308                | Guard::Not { .. }
309                | Guard::Or { .. }
310                | Guard::AnyOf { .. }
311                | Guard::IntGt { .. }
312                | Guard::IntLt { .. }
313                | Guard::AtMostOneMember { .. }
314                | Guard::MinMembers { .. }
315                | Guard::HasKey { .. }
316                | Guard::ContainsEquals { .. },
317            ) => {}
318        }
319    }
320
321    /// Projects a predicate stack into a deduplicated guard conjunction.
322    #[must_use]
323    pub fn contract_guard_stack(predicates: &[Self]) -> Vec<Guard> {
324        let mut guards = Vec::new();
325        for predicate in predicates {
326            for guard in predicate.contract_guards() {
327                if !guards.contains(&guard) {
328                    guards.push(guard);
329                }
330            }
331        }
332        guards
333    }
334
335    /// Rewrites every values path carried by this formula.
336    #[must_use]
337    pub fn map_value_paths<F>(self, map: &mut F) -> Self
338    where
339        F: FnMut(&str) -> String,
340    {
341        match self {
342            Self::True => Self::True,
343            Self::False => Self::False,
344            Self::Approximate {
345                marker,
346                paths,
347                sound_subset,
348            } => Self::Approximate {
349                marker,
350                paths: paths.into_iter().map(|path| map(&path)).collect(),
351                sound_subset: sound_subset
352                    .into_iter()
353                    .map(|guard| guard.map_value_paths(map))
354                    .collect(),
355            },
356            Self::Guard(guard) => Self::Guard(guard.map_value_paths(map)),
357            Self::Not(inner) => Self::Not(Box::new(inner.map_value_paths(map))),
358            Self::And(predicates) => Self::And(
359                predicates
360                    .into_iter()
361                    .map(|predicate| predicate.map_value_paths(map))
362                    .collect(),
363            ),
364            Self::Or(predicates) => Self::Or(
365                predicates
366                    .into_iter()
367                    .map(|predicate| predicate.map_value_paths(map))
368                    .collect(),
369            ),
370        }
371    }
372}
373
374/// Whether [`negated_contract_guards`] flattens `¬inner` exactly: every
375/// De Morgan leaf must be a negatable guard (`True`/`False` leaves are
376/// excluded — the guard vocabulary cannot spell a constant).
377fn negation_flattens_exactly(inner: &Predicate) -> bool {
378    match inner {
379        Predicate::Guard(
380            Guard::Truthy { .. }
381            | Guard::With { .. }
382            | Guard::Not { .. }
383            | Guard::Or { .. }
384            | Guard::Eq { .. }
385            | Guard::NotEq { .. }
386            | Guard::TypeIs { .. }
387            | Guard::NotTypeIs { .. },
388        ) => true,
389        Predicate::Not(inner) => inner.contract_guards_are_exact(),
390        Predicate::And(predicates) | Predicate::Or(predicates) => {
391            predicates.iter().all(negation_flattens_exactly)
392        }
393        _ => false,
394    }
395}
396
397fn negated_contract_guards(inner: &Predicate) -> Vec<Guard> {
398    match inner {
399        Predicate::Guard(Guard::Truthy { path } | Guard::With { path }) => {
400            vec![Guard::Not { path: path.clone() }]
401        }
402        Predicate::Guard(Guard::Not { path }) => vec![Guard::Truthy { path: path.clone() }],
403        Predicate::Guard(Guard::Or { paths }) => paths
404            .iter()
405            .map(|path| Guard::Not { path: path.clone() })
406            .collect(),
407        Predicate::Guard(Guard::Eq { path, value }) => vec![Guard::NotEq {
408            path: path.clone(),
409            value: value.clone(),
410        }],
411        Predicate::Guard(Guard::NotEq { path, value }) => vec![Guard::Eq {
412            path: path.clone(),
413            value: value.clone(),
414        }],
415        Predicate::Guard(Guard::TypeIs { path, schema_type }) => vec![Guard::NotTypeIs {
416            path: path.clone(),
417            schema_type: schema_type.clone(),
418        }],
419        Predicate::Guard(Guard::NotTypeIs { path, schema_type }) => vec![Guard::TypeIs {
420            path: path.clone(),
421            schema_type: schema_type.clone(),
422        }],
423        Predicate::Not(inner) => inner.contract_guards(),
424        // ¬(p₁ ∨ … ∨ pₙ) = ¬p₁ ∧ … ∧ ¬pₙ: a plain conjunction, exact only
425        // when every disjunct negates exactly (an empty flatten anywhere
426        // abstains the whole negation instead of silently dropping it).
427        Predicate::Or(predicates) => {
428            let mut guards = Vec::new();
429            for predicate in predicates {
430                let negated = negated_contract_guards(predicate);
431                if negated.is_empty() {
432                    return Vec::new();
433                }
434                guards.extend(negated);
435            }
436            guards
437        }
438        // ¬(p₁ ∧ … ∧ pₙ) = ¬p₁ ∨ … ∨ ¬pₙ: one alternative per conjunct,
439        // sharing the disjunction normalization of the positive `Or` lane.
440        Predicate::And(predicates) => {
441            let mut alternatives = Vec::new();
442            for predicate in predicates {
443                let negated = negated_contract_guards(predicate);
444                if negated.is_empty() {
445                    return Vec::new();
446                }
447                alternatives.push(negated);
448            }
449            alternatives_to_guards(alternatives)
450        }
451        _ => Vec::new(),
452    }
453}
454
455fn or_contract_guards(predicates: &[Predicate]) -> Vec<Guard> {
456    let alternatives = predicates
457        .iter()
458        .map(Predicate::contract_guards)
459        .collect::<Vec<_>>();
460
461    if alternatives.iter().any(Vec::is_empty) {
462        return Vec::new();
463    }
464    alternatives_to_guards(alternatives)
465}
466
467/// Normalize a disjunction of guard conjunctions into guard form: a single
468/// alternative collapses to its conjunction, all-truthy alternatives become
469/// the flat [`Guard::Or`], anything else the general [`Guard::AnyOf`].
470fn alternatives_to_guards(mut alternatives: Vec<Vec<Guard>>) -> Vec<Guard> {
471    for alternative in &mut alternatives {
472        alternative.sort();
473        alternative.dedup();
474    }
475    alternatives.sort();
476    alternatives.dedup();
477
478    if alternatives.len() == 1 {
479        return alternatives.pop().unwrap_or_default();
480    }
481
482    if let Some(paths) = truthy_or_paths(&alternatives) {
483        return vec![Guard::Or { paths }];
484    }
485
486    vec![Guard::AnyOf { alternatives }]
487}
488
489fn truthy_or_paths(alternatives: &[Vec<Guard>]) -> Option<Vec<String>> {
490    alternatives
491        .iter()
492        .map(|alternative| match alternative.as_slice() {
493            [Guard::Truthy { path }] => Some(path.clone()),
494            _ => None,
495        })
496        .collect()
497}
498
499#[cfg(test)]
500#[path = "tests/predicate.rs"]
501mod tests;