Skip to main content

template_model/
budget.rs

1//! Deterministic, thread-safe, deduplicating tracker for validation budget
2//! exhaustions. Budget kinds form a finite enum — no unbounded per-path or
3//! per-query detail is stored. The tracker is designed to be shared across
4//! model construction (resolver, condition model) and downstream consumers
5//! (schema validator, rule engines) through the `SemanticModel`.
6
7use crate::consts::{
8    MAX_COMPLETE_SCENARIO_PRODUCT, MAX_ENUM_EXPANSION, MAX_FINGERPRINT_DEPTH, MAX_PARAM_COMBINATIONS,
9    MAX_REQUIRED_PROPERTY_COMBINATIONS, MAX_RESOLVE_DEPTH, MAX_SAT_ITERATIONS, MAX_SCENARIO_COMBINATIONS,
10    MAX_SCHEMA_MATCH_DEPTH, MAX_SCHEMA_SCENARIO_ASSIGNMENTS, MAX_SCHEMA_SCENARIO_MERGE_ATTEMPTS,
11    MAX_TOTAL_SAT_ITERATIONS, MAX_TOTAL_SCENARIO_COMBINATIONS,
12};
13use std::collections::BTreeSet;
14use std::sync::Mutex;
15
16/// A finite set of deterministic validation budgets. Each variant names the
17/// budget that was exhausted; no occurrence count, path, or query detail is
18/// stored so the tracker remains bounded and order-insensitive.
19///
20/// `as_str` provides the stable lower camelCase identifier used by report
21/// consumers. `description` provides a human-readable sentence.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
23pub enum BudgetKind {
24    /// Intrinsic-resolution recursion depth.
25    ResolverDepth,
26    /// Per-value variant expansion during intrinsic resolution.
27    EnumExpansion,
28    /// Per-value scenario-combination expansion.
29    ScenarioCombinationsPerValue,
30    /// Joint list/map scenario products replaced by an alternative-covering set.
31    ScenarioProductCovering,
32    /// Cumulative scenario-combination work across all values.
33    ScenarioCombinationsTotal,
34    /// Condition parameter-space pre-filter.
35    ConditionParameterCombinations,
36    /// Per-query satisfiability search steps.
37    ConditionSatIterationsPerQuery,
38    /// Cumulative satisfiability search steps across all queries.
39    ConditionSatIterationsTotal,
40    /// Schema scenario-assignment expansion per property group.
41    SchemaScenarioAssignments,
42    /// Schema scenario merge attempts per property group.
43    SchemaScenarioMergeAttempts,
44    /// Schema recursive match depth.
45    SchemaMatchDepth,
46    /// Required-property combination expansion. Context-only: reaching this cap
47    /// truncates the diagnostic explanation without omitting the finding.
48    RequiredPropertyCombinations,
49    /// Expression fingerprint depth.
50    ExpressionFingerprintDepth,
51}
52
53impl BudgetKind {
54    /// The numeric limit for this budget kind.
55    pub const fn limit(self) -> u64 {
56        match self {
57            Self::ResolverDepth => MAX_RESOLVE_DEPTH as u64,
58            Self::EnumExpansion => MAX_ENUM_EXPANSION as u64,
59            Self::ScenarioCombinationsPerValue => MAX_SCENARIO_COMBINATIONS as u64,
60            Self::ScenarioProductCovering => MAX_COMPLETE_SCENARIO_PRODUCT as u64,
61            Self::ScenarioCombinationsTotal => MAX_TOTAL_SCENARIO_COMBINATIONS,
62            Self::ConditionParameterCombinations => MAX_PARAM_COMBINATIONS,
63            Self::ConditionSatIterationsPerQuery => MAX_SAT_ITERATIONS,
64            Self::ConditionSatIterationsTotal => MAX_TOTAL_SAT_ITERATIONS,
65            Self::SchemaScenarioAssignments => MAX_SCHEMA_SCENARIO_ASSIGNMENTS as u64,
66            Self::SchemaScenarioMergeAttempts => MAX_SCHEMA_SCENARIO_MERGE_ATTEMPTS as u64,
67            Self::SchemaMatchDepth => MAX_SCHEMA_MATCH_DEPTH as u64,
68            Self::RequiredPropertyCombinations => MAX_REQUIRED_PROPERTY_COMBINATIONS as u64,
69            Self::ExpressionFingerprintDepth => MAX_FINGERPRINT_DEPTH as u64,
70        }
71    }
72
73    /// Whether exhausting this budget makes the overall analysis incomplete.
74    /// Required-property combination exhaustion only truncates a diagnostic's
75    /// explanation; it does not affect whether the finding is emitted.
76    pub const fn analysis_incomplete(self) -> bool {
77        !matches!(self, Self::RequiredPropertyCombinations)
78    }
79
80    /// Lower camelCase string representation for stable machine consumption.
81    pub const fn as_str(self) -> &'static str {
82        match self {
83            Self::ResolverDepth => "resolverDepth",
84            Self::EnumExpansion => "enumExpansion",
85            Self::ScenarioCombinationsPerValue => "scenarioCombinationsPerValue",
86            Self::ScenarioProductCovering => "scenarioProductCovering",
87            Self::ScenarioCombinationsTotal => "scenarioCombinationsTotal",
88            Self::ConditionParameterCombinations => "conditionParameterCombinations",
89            Self::ConditionSatIterationsPerQuery => "conditionSatIterationsPerQuery",
90            Self::ConditionSatIterationsTotal => "conditionSatIterationsTotal",
91            Self::SchemaScenarioAssignments => "schemaScenarioAssignments",
92            Self::SchemaScenarioMergeAttempts => "schemaScenarioMergeAttempts",
93            Self::SchemaMatchDepth => "schemaMatchDepth",
94            Self::RequiredPropertyCombinations => "requiredPropertyCombinations",
95            Self::ExpressionFingerprintDepth => "expressionFingerprintDepth",
96        }
97    }
98
99    /// Human-readable explanation of the exhausted budget. This prose may evolve;
100    /// integrations should use `as_str` as the stable identifier.
101    pub const fn description(self) -> &'static str {
102        match self {
103            Self::ResolverDepth => "Resolving intrinsic functions exceeded the maximum supported nesting depth.",
104            Self::EnumExpansion => "Resolving a value produced more possible values than the supported limit.",
105            Self::ScenarioCombinationsPerValue => {
106                "Resolving a value produced more conditional scenarios than the supported limit."
107            }
108            Self::ScenarioProductCovering => {
109                "Resolving a container replaced its joint conditional scenarios with an alternative-covering set."
110            }
111            Self::ScenarioCombinationsTotal => {
112                "Resolving the template produced more conditional scenarios than the supported cumulative limit."
113            }
114            Self::ConditionParameterCombinations => {
115                "Evaluating template conditions required more parameter combinations than the supported limit."
116            }
117            Self::ConditionSatIterationsPerQuery => {
118                "Evaluating one set of template conditions exceeded the supported work limit."
119            }
120            Self::ConditionSatIterationsTotal => {
121                "Evaluating template conditions exceeded the supported cumulative work limit."
122            }
123            Self::SchemaScenarioAssignments => {
124                "Checking resource schemas required more conditional value assignments than the supported limit."
125            }
126            Self::SchemaScenarioMergeAttempts => {
127                "Checking resource schemas exceeded the supported number of conditional assignment merge attempts."
128            }
129            Self::SchemaMatchDepth => {
130                "Checking resource schemas exceeded the maximum supported recursive matching depth."
131            }
132            Self::RequiredPropertyCombinations => {
133                "Checking required properties omitted some explanation combinations after reaching the supported limit."
134            }
135            Self::ExpressionFingerprintDepth => {
136                "Comparing resolved expressions exceeded the maximum supported nesting depth."
137            }
138        }
139    }
140}
141
142/// Thread-safe, deduplicating budget-exhaustion tracker. Keyed only by
143/// `BudgetKind` — no per-path or per-query detail is stored. Uses a `BTreeSet`
144/// for deterministic iteration order.
145#[derive(Debug, Default)]
146pub(crate) struct BudgetTracker {
147    exhausted: Mutex<BTreeSet<BudgetKind>>,
148}
149
150impl BudgetTracker {
151    pub(crate) fn new() -> Self {
152        Self { exhausted: Mutex::new(BTreeSet::new()) }
153    }
154
155    /// Record that a budget kind was exhausted. Duplicate insertions are no-ops.
156    pub(crate) fn record(&self, kind: BudgetKind) {
157        self.exhausted.lock().unwrap_or_else(|p| p.into_inner()).insert(kind);
158    }
159
160    /// Returns the set of exhausted budget kinds in deterministic order.
161    pub(crate) fn exhausted_kinds(&self) -> BTreeSet<BudgetKind> {
162        self.exhausted.lock().unwrap_or_else(|p| p.into_inner()).clone()
163    }
164
165    /// Whether any exhausted budget makes the analysis incomplete.
166    pub(crate) fn analysis_incomplete(&self) -> bool {
167        self.exhausted.lock().unwrap_or_else(|p| p.into_inner()).iter().any(|kind| kind.analysis_incomplete())
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn record_deduplicates() {
177        let tracker = BudgetTracker::new();
178        tracker.record(BudgetKind::ResolverDepth);
179        tracker.record(BudgetKind::ResolverDepth);
180        assert_eq!(tracker.exhausted_kinds().len(), 1);
181    }
182
183    #[test]
184    fn exhausted_kinds_deterministic_order() {
185        let tracker = BudgetTracker::new();
186        tracker.record(BudgetKind::ExpressionFingerprintDepth);
187        tracker.record(BudgetKind::ResolverDepth);
188        tracker.record(BudgetKind::EnumExpansion);
189        let kinds: Vec<_> = tracker.exhausted_kinds().into_iter().collect();
190        assert_eq!(kinds[0], BudgetKind::ResolverDepth);
191        assert_eq!(kinds[1], BudgetKind::EnumExpansion);
192        assert_eq!(kinds[2], BudgetKind::ExpressionFingerprintDepth);
193    }
194
195    #[test]
196    fn analysis_incomplete_reflects_kind() {
197        let tracker = BudgetTracker::new();
198        tracker.record(BudgetKind::RequiredPropertyCombinations);
199        assert!(!tracker.analysis_incomplete());
200
201        tracker.record(BudgetKind::ResolverDepth);
202        assert!(tracker.analysis_incomplete());
203    }
204
205    #[test]
206    fn limit_values_match_canonical_constants() {
207        use crate::consts::*;
208        assert_eq!(BudgetKind::ResolverDepth.limit(), MAX_RESOLVE_DEPTH as u64);
209        assert_eq!(BudgetKind::EnumExpansion.limit(), MAX_ENUM_EXPANSION as u64);
210        assert_eq!(BudgetKind::ScenarioCombinationsPerValue.limit(), MAX_SCENARIO_COMBINATIONS as u64);
211        assert_eq!(BudgetKind::ScenarioCombinationsTotal.limit(), MAX_TOTAL_SCENARIO_COMBINATIONS);
212        assert_eq!(BudgetKind::ConditionParameterCombinations.limit(), MAX_PARAM_COMBINATIONS);
213        assert_eq!(BudgetKind::ConditionSatIterationsPerQuery.limit(), MAX_SAT_ITERATIONS);
214        assert_eq!(BudgetKind::ConditionSatIterationsTotal.limit(), MAX_TOTAL_SAT_ITERATIONS);
215        assert_eq!(BudgetKind::SchemaScenarioAssignments.limit(), MAX_SCHEMA_SCENARIO_ASSIGNMENTS as u64);
216        assert_eq!(BudgetKind::SchemaScenarioMergeAttempts.limit(), MAX_SCHEMA_SCENARIO_MERGE_ATTEMPTS as u64);
217        assert_eq!(BudgetKind::SchemaMatchDepth.limit(), MAX_SCHEMA_MATCH_DEPTH as u64);
218        assert_eq!(BudgetKind::RequiredPropertyCombinations.limit(), MAX_REQUIRED_PROPERTY_COMBINATIONS as u64);
219        assert_eq!(BudgetKind::ExpressionFingerprintDepth.limit(), MAX_FINGERPRINT_DEPTH as u64);
220    }
221
222    #[test]
223    fn as_str_lower_camel_case() {
224        assert_eq!(BudgetKind::ResolverDepth.as_str(), "resolverDepth");
225        assert_eq!(BudgetKind::EnumExpansion.as_str(), "enumExpansion");
226        assert_eq!(BudgetKind::ScenarioCombinationsPerValue.as_str(), "scenarioCombinationsPerValue");
227        assert_eq!(BudgetKind::ConditionSatIterationsPerQuery.as_str(), "conditionSatIterationsPerQuery");
228        assert_eq!(BudgetKind::SchemaMatchDepth.as_str(), "schemaMatchDepth");
229        assert_eq!(BudgetKind::RequiredPropertyCombinations.as_str(), "requiredPropertyCombinations");
230        assert_eq!(BudgetKind::ExpressionFingerprintDepth.as_str(), "expressionFingerprintDepth");
231    }
232
233    #[test]
234    fn descriptions_are_human_readable_sentences() {
235        let descriptions = [
236            BudgetKind::ResolverDepth.description(),
237            BudgetKind::EnumExpansion.description(),
238            BudgetKind::ScenarioCombinationsPerValue.description(),
239            BudgetKind::ScenarioCombinationsTotal.description(),
240            BudgetKind::ConditionParameterCombinations.description(),
241            BudgetKind::ConditionSatIterationsPerQuery.description(),
242            BudgetKind::ConditionSatIterationsTotal.description(),
243            BudgetKind::SchemaScenarioAssignments.description(),
244            BudgetKind::SchemaScenarioMergeAttempts.description(),
245            BudgetKind::SchemaMatchDepth.description(),
246            BudgetKind::RequiredPropertyCombinations.description(),
247            BudgetKind::ExpressionFingerprintDepth.description(),
248        ];
249
250        assert!(descriptions.iter().all(|description| description.ends_with('.')));
251        assert_eq!(
252            BudgetKind::ConditionParameterCombinations.description(),
253            "Evaluating template conditions required more parameter combinations than the supported limit."
254        );
255        assert_eq!(
256            BudgetKind::RequiredPropertyCombinations.description(),
257            "Checking required properties omitted some explanation combinations after reaching the supported limit."
258        );
259    }
260}