Skip to main content

template_model/
conditions.rs

1use crate::budget::{BudgetKind, BudgetTracker};
2use crate::consts::{
3    CONDITION_REF_PREFIX, FN_AND, FN_CONDITION, FN_EQUALS, FN_NOT, FN_OR, MAX_PARAM_COMBINATIONS, MAX_SAT_ITERATIONS,
4    MAX_TOTAL_SAT_ITERATIONS, PARAM_UNKNOWN_SENTINEL, PSEUDO_PREFIX, PSEUDO_REGION,
5};
6use crate::ir::*;
7use crate::model::PseudoParameterOverrides;
8use crate::resolver::{MappingData, ParameterInfo};
9use log::{debug, info};
10use std::collections::{HashMap, HashSet};
11use std::sync::atomic::{AtomicU64, Ordering};
12use std::sync::{Arc, OnceLock};
13
14/// How many budget-exhausted satisfiability queries are retained for the
15/// advisory diagnostic. The advisory tells an author that analysis of their
16/// condition set was cut short; a handful of examples conveys that, while
17/// retaining every query on a pathological template would itself grow unbounded.
18const MAX_REPORTED_BUDGET_EXHAUSTED_QUERIES: usize = 5;
19
20/// How the advisory describes analysis cut short by the cumulative budget rather
21/// than by one expensive query: from that point on, every remaining question about
22/// the condition set gets the conservative answer.
23const WHOLE_CONDITION_SET_DESCRIPTION: &str =
24    "this template's condition set as a whole (remaining conditions were assumed compatible)";
25const INLINE_CONDITION_DESCRIPTION: &str = "<inline condition>";
26const SYNTHETIC_CONDITION_PREFIX: &str = "__";
27
28/// Result of a satisfiability query when callers need to distinguish an exact
29/// answer from the conservative answer returned after a search budget is spent.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum Satisfiability {
32    Satisfiable,
33    Unsatisfiable,
34    Unknown,
35}
36
37#[derive(Debug, Clone)]
38pub enum ConditionExpr {
39    Equals(ValueExpr, ValueExpr),
40    And(Vec<ConditionExpr>),
41    Or(Vec<ConditionExpr>),
42    Not(Box<ConditionExpr>),
43    ConditionRef(String),
44    /// A condition body that does not produce a boolean (e.g. a bare `Fn::Ref`,
45    /// a scalar, or a value-producing function). CloudFormation rejects it; it is
46    /// reported as a not-a-boolean error and is deliberately opaque to the SAT
47    /// model and to the undefined-reference check (it is not a condition
48    /// reference).
49    Invalid,
50}
51
52#[derive(Debug, Clone)]
53pub enum ValueExpr {
54    ParamRef(String),
55    Literal(String),
56    PseudoParam(String),
57    MappingLookup { map_name: String, key1: Box<ValueExpr>, key2: Box<ValueExpr> },
58    Other,
59}
60
61#[derive(Debug, Clone)]
62pub struct MutexGroup {
63    pub conditions: Vec<String>,
64    pub parameter: String,
65    pub values: Vec<String>,
66}
67
68#[derive(Debug, Clone)]
69pub struct Implication {
70    pub antecedent: String,
71    pub consequent: String,
72}
73
74#[derive(Debug)]
75pub struct ConditionModel {
76    pub conditions: HashMap<String, ConditionExpr>,
77    pub parameters: HashMap<String, ParameterInfo>,
78    pub mutex_groups: Vec<MutexGroup>,
79    pub implications: Vec<Implication>,
80    /// Constraints a `Rules` section imposes on the parameters, as
81    /// `rule condition => assertion` pairs over synthetic conditions. Kept apart
82    /// from `implications` - which the `And`/`Or` structure of the conditions
83    /// entails and satisfiability therefore rederives on its own - because these
84    /// are external restrictions on what a deployment may supply and are the only
85    /// constraints the satisfiability search has to apply itself. Separate
86    /// storage also means recomputing the derived `implications` cannot discard
87    /// them.
88    rule_implications: Vec<Implication>,
89    pseudo_overrides: PseudoParameterOverrides,
90    mappings: MappingData,
91    /// Cumulative satisfiability search steps consumed across every query for
92    /// this model. Shared (the model is reached through one `Arc`) so the
93    /// quadratic compatibility pass, scenario resolution, and rule-evaluation
94    /// builtins all draw down a single per-validation budget.
95    sat_iterations_used: AtomicU64,
96    /// Referenced parameters and their candidate values, derived from the
97    /// condition set and parameter definitions. Every satisfiability query reads
98    /// the candidate values of the parameters it depends on, so caching this
99    /// avoids rederiving the same map across queries. Computed once on first use
100    /// and invalidated by `register_inline` - the only path that mutates the
101    /// condition set after construction.
102    referenced_param_values: OnceLock<HashMap<String, Vec<String>>>,
103    budget_exhausted_queries: std::sync::Mutex<Vec<String>>,
104    /// Budget exhaustions are shared with the owning semantic model, or kept in
105    /// a standalone tracker when this condition model is constructed directly.
106    budget_tracker: Arc<BudgetTracker>,
107}
108
109pub fn format_condition_expr(expr: &ConditionExpr) -> String {
110    match expr {
111        ConditionExpr::Equals(a, b) => {
112            format!("Equals({}, {})", format_value_expr(a), format_value_expr(b))
113        }
114        ConditionExpr::And(exprs) => {
115            let items: Vec<String> = exprs.iter().map(format_condition_expr).collect();
116            format!("And({})", items.join(", "))
117        }
118        ConditionExpr::Or(exprs) => {
119            let items: Vec<String> = exprs.iter().map(format_condition_expr).collect();
120            format!("Or({})", items.join(", "))
121        }
122        ConditionExpr::Not(e) => format!("Not({})", format_condition_expr(e)),
123        ConditionExpr::ConditionRef(name) => format!("Condition({})", name),
124        ConditionExpr::Invalid => "Invalid".to_string(),
125    }
126}
127
128fn format_value_expr(expr: &ValueExpr) -> String {
129    match expr {
130        ValueExpr::ParamRef(name) => format!("Param({})", name),
131        ValueExpr::Literal(s) => format!("\"{}\"", s),
132        ValueExpr::PseudoParam(name) => name.clone(),
133        ValueExpr::MappingLookup { map_name, key1, key2 } => {
134            format!("FindInMap({}, {}, {})", map_name, format_value_expr(key1), format_value_expr(key2))
135        }
136        ValueExpr::Other => "?".into(),
137    }
138}
139
140fn format_satisfiability_assumptions(assumptions: &[(String, bool)]) -> String {
141    let mut descriptions = assumptions
142        .iter()
143        .map(|(name, value)| {
144            let condition_description =
145                if name.starts_with(SYNTHETIC_CONDITION_PREFIX) { INLINE_CONDITION_DESCRIPTION } else { name };
146            format!("{condition_description}={value}")
147        })
148        .collect::<Vec<_>>();
149    descriptions.sort_unstable();
150    descriptions.join(", ")
151}
152
153impl ConditionModel {
154    pub fn from_ir(
155        ir: &TemplateIR,
156        parameters: &HashMap<String, ParameterInfo>,
157        pseudo_overrides: &PseudoParameterOverrides,
158        mappings: &MappingData,
159    ) -> Self {
160        Self::from_ir_with_tracker(ir, parameters, pseudo_overrides, mappings, Arc::new(BudgetTracker::new()))
161    }
162
163    /// Constructs a `ConditionModel` with a pre-created shared budget tracker.
164    /// Used by `SemanticModel::parse` to share a single tracker across model
165    /// construction and downstream consumers.
166    pub(crate) fn from_ir_with_tracker(
167        ir: &TemplateIR,
168        parameters: &HashMap<String, ParameterInfo>,
169        pseudo_overrides: &PseudoParameterOverrides,
170        mappings: &MappingData,
171        tracker: Arc<BudgetTracker>,
172    ) -> Self {
173        let mut conditions = HashMap::new();
174
175        if ir.conditions != NULL_REF
176            && let Some(entries) = ir.arena.as_map(ir.conditions)
177        {
178            for (name, node_ref) in entries {
179                let expr = parse_condition_expr(&ir.arena, *node_ref, parameters);
180                conditions.insert(name.clone(), expr);
181            }
182        }
183
184        let mutex_groups = extract_mutex_groups(&conditions);
185        let implications = extract_implications(&conditions);
186
187        info!(
188            "Condition model: {} conditions, {} mutex groups (params: {:?}), {} implications",
189            conditions.len(),
190            mutex_groups.len(),
191            mutex_groups.iter().map(|g| g.parameter.as_str()).collect::<Vec<_>>(),
192            implications.len()
193        );
194        ConditionModel {
195            conditions,
196            parameters: parameters.clone(),
197            mutex_groups,
198            implications,
199            rule_implications: Vec::new(),
200            pseudo_overrides: pseudo_overrides.clone(),
201            mappings: mappings.clone(),
202            sat_iterations_used: AtomicU64::new(0),
203            referenced_param_values: OnceLock::new(),
204            budget_exhausted_queries: std::sync::Mutex::new(Vec::new()),
205            budget_tracker: tracker,
206        }
207    }
208
209    /// Decides whether the given condition assumptions can hold simultaneously.
210    ///
211    /// A condition is a pure function of the template's parameters,
212    /// pseudo-parameters, and mappings, so the assumptions hold exactly when some
213    /// assignment of concrete values to the parameters they read makes every
214    /// assumed condition take its assumed value. The search is over those
215    /// parameter assignments, which is why its cost tracks how many independent
216    /// inputs a template really has rather than how many conditions are layered
217    /// over them.
218    ///
219    /// On inputs that exhaust a search budget this degrades to a conservative
220    /// `true` (assume satisfiable). Whether that conservative answer *adds* or
221    /// *suppresses* a diagnostic depends on how each caller uses the result, so
222    /// "conservative" is not safe in one fixed direction. The callers below are
223    /// examples, not an exhaustive list:
224    ///
225    /// - Used directly (`conditions_compatible`, `resources_compatible`, and the
226    ///   scenario-reachability filters in `resolve_scenarios_json` and the schema
227    ///   validator) a conservative `true` treats two conditions as able to
228    ///   coexist / a scenario as reachable, so it keeps a pair or scenario a full
229    ///   search might have eliminated - at worst surfacing an extra
230    ///   (false-positive) diagnostic.
231    /// - Used negated to prove *unreachability* (`find_unreachable_branches`,
232    ///   which emits when `!is_satisfiable(branch_assumptions)`) a conservative
233    ///   `true` makes the branch look reachable and therefore *suppresses* an
234    ///   unreachable-branch diagnostic - a false negative, the opposite
235    ///   direction.
236    /// - Used negated to prove *implication* (`condition_implies`, i.e.
237    ///   `!is_satisfiable(a = true, b = false)`; the conditional-reference guard
238    ///   uses the same shape) a conservative `true` flips the result to "does not
239    ///   imply", which can surface an extra (false-positive) diagnostic.
240    ///
241    /// All of these occur only on adversarial inputs that exhaust the budget;
242    /// valid templates resolve well within budget and are unaffected. Reaching a
243    /// budget is recorded (see [`Self::budget_exhausted_queries`]) so a curtailed
244    /// analysis is reported rather than silently narrowing what validation proves.
245    ///
246    /// A condition the parameters cannot decide - one comparing a value the model
247    /// cannot resolve, or sitting on a reference cycle - is treated as compatible
248    /// with any assumption about it, the same conservative direction.
249    #[must_use]
250    pub fn is_satisfiable(&self, assumptions: &[(String, bool)]) -> bool {
251        self.satisfiability(assumptions) != Satisfiability::Unsatisfiable
252    }
253
254    /// Decides satisfiability without conflating a budget-curtailed search with
255    /// an exact satisfiable result. Proof-producing callers must require
256    /// [`Satisfiability::Satisfiable`]; callers that use satisfiability only as a
257    /// conservative reachability guard can continue using [`Self::is_satisfiable`].
258    #[must_use]
259    pub fn satisfiability(&self, assumptions: &[(String, bool)]) -> Satisfiability {
260        self.satisfiability_with_param_overrides(assumptions, &HashMap::new())
261    }
262
263    /// Like [`Self::is_satisfiable`], but with the target region pinned as the
264    /// only candidate value for the `AWS::Region` pseudo-parameter - a per-region
265    /// satisfiability query that asks whether a condition can hold *in that
266    /// region*, not whether the region could be anything. Used by the
267    /// region-availability check so a resource guarded by a
268    /// condition that cannot hold in the target region (e.g.
269    /// `!Equals [AWS::Region, other-region]`) is correctly treated as never
270    /// created there - even when no explicit `--region` override pins the
271    /// pseudo-parameter globally (where it stays a free variable to avoid
272    /// false unreachable-branch diagnostics).
273    #[must_use]
274    pub fn is_satisfiable_in_region(&self, assumptions: &[(String, bool)], region: &str) -> bool {
275        let overrides = HashMap::from([(PSEUDO_REGION.to_string(), vec![region.to_string()])]);
276        self.satisfiability_with_param_overrides(assumptions, &overrides) != Satisfiability::Unsatisfiable
277    }
278
279    /// Satisfiability with a set of parameter/pseudo-parameter candidate-value
280    /// overrides applied on top of the model's derived candidate values. An
281    /// override restricts a parameter to exactly the given values for this query
282    /// only; a parameter not referenced by any relevant condition is unaffected.
283    #[must_use]
284    fn satisfiability_with_param_overrides<'model>(
285        &'model self,
286        assumptions: &[(String, bool)],
287        param_overrides: &'model HashMap<String, Vec<String>>,
288    ) -> Satisfiability {
289        // Once the cumulative search budget for this model is spent, no further
290        // query can provide an exact answer.
291        if self.satisfiability_budget_exhausted() {
292            return Satisfiability::Unknown;
293        }
294
295        let mut assumed: HashMap<&'model str, bool> = HashMap::with_capacity(assumptions.len());
296        for (name, expected) in assumptions {
297            // A name that names no condition of this template fixes no
298            // expression's value, so it constrains nothing.
299            let Some((condition_name, _)) = self.conditions.get_key_value(name.as_str()) else {
300                continue;
301            };
302            if let Some(&already_assumed) = assumed.get(condition_name.as_str())
303                && already_assumed != *expected
304            {
305                return Satisfiability::Unsatisfiable;
306            }
307            assumed.insert(condition_name.as_str(), *expected);
308        }
309        if assumed.is_empty() {
310            return Satisfiability::Satisfiable;
311        }
312        debug!("Checking satisfiability of {:?} against {} conditions", assumptions, self.conditions.len());
313
314        let mut query = SatisfiabilityQuery::prepare(self, &assumed, param_overrides);
315
316        // If even the restricted parameter space this query depends on is too
317        // large to enumerate, the answer is unknown rather than proven
318        // satisfiable. The boolean API maps this back to its historical
319        // conservative `true` result.
320        if query.parameter_point_count() > MAX_PARAM_COMBINATIONS {
321            let steps = query.steps;
322            self.charge_satisfiability_work(steps);
323            let query_description = format_satisfiability_assumptions(assumptions);
324            self.record_curtailed_analysis(query_description);
325            self.budget_tracker.record(BudgetKind::ConditionParameterCombinations);
326            return Satisfiability::Unknown;
327        }
328
329        let satisfiable = query.some_point_satisfies(&assumed);
330        let steps = query.steps;
331        self.charge_satisfiability_work(steps);
332        if steps > MAX_SAT_ITERATIONS {
333            // Synthetic condition names (inline Fn::If expressions and
334            // Rules-section conditions) are internal; describe them generically
335            // rather than leaking `__`-prefixed identifiers into the advisory.
336            let query_description = format_satisfiability_assumptions(assumptions);
337            self.record_curtailed_analysis(query_description);
338            self.budget_tracker.record(BudgetKind::ConditionSatIterationsPerQuery);
339            return Satisfiability::Unknown;
340        }
341        if satisfiable { Satisfiability::Satisfiable } else { Satisfiability::Unsatisfiable }
342    }
343
344    /// Charges satisfiability work to this model's cumulative budget, reporting the
345    /// crossing so an analysis that silently stops deciding conditions cannot go
346    /// unnoticed by the author of the template that caused it.
347    fn charge_satisfiability_work(&self, steps: u64) {
348        let before = self.sat_iterations_used.fetch_add(steps, Ordering::Relaxed);
349        if before < MAX_TOTAL_SAT_ITERATIONS && before.saturating_add(steps) >= MAX_TOTAL_SAT_ITERATIONS {
350            self.record_curtailed_analysis(WHOLE_CONDITION_SET_DESCRIPTION.to_string());
351            self.budget_tracker.record(BudgetKind::ConditionSatIterationsTotal);
352        }
353    }
354
355    /// Records what a spent budget stopped short of deciding, for the advisory
356    /// diagnostic the validation reports.
357    fn record_curtailed_analysis(&self, description: String) {
358        if let Ok(mut curtailed) = self.budget_exhausted_queries.lock()
359            && curtailed.len() < MAX_REPORTED_BUDGET_EXHAUSTED_QUERIES
360        {
361            curtailed.push(description);
362        }
363    }
364
365    /// Whether this model has spent its cumulative satisfiability search budget.
366    /// Callers that drive many queries (the pairwise compatibility pass) check
367    /// this to stop early once further queries would only return the
368    /// conservative answer.
369    #[must_use]
370    pub fn satisfiability_budget_exhausted(&self) -> bool {
371        self.sat_iterations_used.load(Ordering::Relaxed) >= MAX_TOTAL_SAT_ITERATIONS
372    }
373
374    /// Cumulative satisfiability search steps consumed by this model so far.
375    #[must_use]
376    pub fn sat_iterations_used(&self) -> u64 {
377        self.sat_iterations_used.load(Ordering::Relaxed)
378    }
379
380    pub fn budget_exhausted_queries(&self) -> Vec<String> {
381        self.budget_exhausted_queries.lock().unwrap_or_else(|p| p.into_inner()).clone()
382    }
383
384    /// Test-only: advance the cumulative satisfiability counter directly, so the
385    /// budget threshold and short-circuit behavior can be exercised without
386    /// burning `MAX_TOTAL_SAT_ITERATIONS` of real search work (which would be
387    /// pointless seconds of computation). Charged through the same path as real
388    /// work so the reporting that accompanies exhaustion is exercised too.
389    #[cfg(test)]
390    fn add_sat_iterations_for_test(&self, count: u64) {
391        self.charge_satisfiability_work(count);
392    }
393
394    /// Builds, per parameter the conditions reference, the candidate values to
395    /// enumerate during satisfiability search. The set used for each parameter
396    /// is:
397    ///
398    /// - **User parameter with `AllowedValues`**: those values exactly.
399    /// - **User parameter without `AllowedValues`**: every literal it is
400    ///   compared against in any condition, plus a sentinel
401    ///   (`PARAM_UNKNOWN_SENTINEL`) standing for "any other value".
402    /// - **Pseudo-parameter pinned by an explicit override** (e.g. user passed
403    ///   `--region us-east-1`): the override value as the only candidate, so
404    ///   the solver treats the pseudo-parameter as a constant.
405    /// - **Pseudo-parameter without an explicit override**: literals plus the
406    ///   `__unknown__` sentinel, treating it as a free variable. Without this,
407    ///   `Fn::Equals[AWS::Partition, "aws"]` would always evaluate true (the
408    ///   default partition is "aws"), incorrectly marking the false branch
409    ///   unreachable on templates that deploy to non-commercial partitions.
410    ///
411    /// Comparing a parameter against only the literals it is actually compared
412    /// against (plus the sentinel) is exact: two values a condition set never
413    /// distinguishes produce identical truth assignments, so one representative
414    /// of the "any other value" class suffices.
415    ///
416    /// Derived purely from the immutable condition set, parameter definitions,
417    /// and pseudo-parameter overrides, so it is computed once and cached.
418    fn collect_referenced_param_values(&self) -> HashMap<String, Vec<String>> {
419        let mut compared_literals: HashMap<String, Vec<String>> = HashMap::new();
420        for expr in self.conditions.values() {
421            collect_equals_pairs(expr, &mut compared_literals);
422        }
423        let mut param_values: HashMap<String, Vec<String>> = HashMap::new();
424        for (param_name, literals) in &compared_literals {
425            if let Some(fixed) = self.pseudo_overrides.fixed_value(param_name) {
426                param_values.insert(param_name.clone(), vec![fixed]);
427                continue;
428            }
429            if let Some(allowed_values) = self.parameters.get(param_name).and_then(|p| p.allowed_values.clone()) {
430                param_values.insert(param_name.clone(), allowed_values);
431                continue;
432            }
433            let mut values = literals.clone();
434            values.push(PARAM_UNKNOWN_SENTINEL.to_string());
435            values.sort();
436            values.dedup();
437            param_values.insert(param_name.clone(), values);
438        }
439        param_values
440    }
441
442    /// The candidate values of every parameter any condition references,
443    /// computed once and cached (see [`Self::collect_referenced_param_values`]).
444    fn referenced_param_values(&self) -> &HashMap<String, Vec<String>> {
445        self.referenced_param_values.get_or_init(|| self.collect_referenced_param_values())
446    }
447
448    #[must_use]
449    pub fn conditions_compatible(&self, cond_a: &str, cond_b: &str) -> bool {
450        self.is_satisfiable(&[(cond_a.into(), true), (cond_b.into(), true)])
451    }
452
453    #[must_use]
454    pub fn condition_implies(&self, cond_a: &str, cond_b: &str) -> bool {
455        // A implies B iff (A=true, B=false) is unsatisfiable
456        // Fast path: same condition always implies itself
457        if cond_a == cond_b {
458            return true;
459        }
460        !self.is_satisfiable(&[(cond_a.into(), true), (cond_b.into(), false)])
461    }
462
463    pub fn resources_compatible(&self, cond_a: Option<&str>, cond_b: Option<&str>) -> bool {
464        match (cond_a, cond_b) {
465            (None, _) | (_, None) => true,
466            (Some(a), Some(b)) => self.conditions_compatible(a, b),
467        }
468    }
469
470    pub fn get(&self, name: &str) -> Option<&ConditionExpr> {
471        self.conditions.get(name)
472    }
473
474    pub fn names(&self) -> impl Iterator<Item = &str> {
475        self.conditions.keys().map(|s| s.as_str())
476    }
477
478    pub fn tautological_equals(&self) -> Vec<(String, bool)> {
479        let mut result = Vec::new();
480        for (name, expr) in &self.conditions {
481            Self::find_tautological(expr, name, &mut result);
482        }
483        result
484    }
485
486    fn find_tautological(expr: &ConditionExpr, cond_name: &str, out: &mut Vec<(String, bool)>) {
487        match expr {
488            ConditionExpr::Equals(a, b) => {
489                let always_equal = match (a, b) {
490                    (ValueExpr::Literal(la), ValueExpr::Literal(lb)) => Some(la == lb),
491                    (ValueExpr::ParamRef(pa), ValueExpr::ParamRef(pb)) => Some(pa == pb),
492                    (ValueExpr::PseudoParam(pa), ValueExpr::PseudoParam(pb)) => Some(pa == pb),
493                    _ => None,
494                };
495                if let Some(equal) = always_equal {
496                    out.push((cond_name.to_string(), equal));
497                }
498            }
499            ConditionExpr::And(children) | ConditionExpr::Or(children) => {
500                for c in children {
501                    Self::find_tautological(c, cond_name, out);
502                }
503            }
504            ConditionExpr::Not(child) => Self::find_tautological(child, cond_name, out),
505            ConditionExpr::ConditionRef(_) | ConditionExpr::Invalid => {}
506        }
507    }
508
509    pub fn referenced_params(&self) -> Vec<String> {
510        let mut params = Vec::new();
511        for expr in self.conditions.values() {
512            collect_param_refs_from_expr(expr, &mut params);
513        }
514        params.sort();
515        params.dedup();
516        params
517    }
518
519    pub fn register_inline(&mut self, name: String, expr: ConditionExpr) {
520        self.register_inline_batch(std::iter::once((name, expr)));
521    }
522
523    /// Registers many inline conditions at once, recomputing the derived mutex
524    /// groups and implications a single time. Registering a large batch through
525    /// `register_inline` would recompute them per insertion (quadratic).
526    pub fn register_inline_batch(&mut self, items: impl IntoIterator<Item = (String, ConditionExpr)>) {
527        for (name, expr) in items {
528            self.conditions.insert(name, expr);
529        }
530        self.mutex_groups = extract_mutex_groups(&self.conditions);
531        self.implications = extract_implications(&self.conditions);
532        // Inserting a condition can introduce parameter references the cached
533        // map omits, so invalidate it; it is rebuilt lazily on the next
534        // satisfiability query. The cumulative iteration counter is deliberately
535        // left untouched - it is a per-validation work budget, not state derived
536        // from the condition set, and resetting it would hand adversarial input a
537        // way to refill the budget.
538        self.referenced_param_values = OnceLock::new();
539    }
540
541    pub fn register_rule_implications(&mut self, arena: &Arena, rules: &[(String, NodeRef, Vec<NodeRef>)]) {
542        // A Rules-section `RuleCondition => Assertions` relationship becomes a set
543        // of constraints `__rule_cond_<rule> => __rule_assert_<rule>_<i>`. They are
544        // kept in their own list because they restrict what a deployment may
545        // supply rather than following from the conditions' structure, so
546        // satisfiability has to apply them explicitly (see `rule_implications`).
547        let mut rule_implications: Vec<Implication> = Vec::new();
548        for (rule_name, condition_node, assertion_nodes) in rules {
549            let antecedent = if *condition_node != NULL_REF {
550                let expr = parse_condition_expr(arena, *condition_node, &self.parameters);
551                let synth_name = format!("__rule_cond_{}", rule_name);
552                self.conditions.insert(synth_name.clone(), expr);
553                Some(synth_name)
554            } else {
555                None
556            };
557
558            for (idx, assert_node) in assertion_nodes.iter().enumerate() {
559                if *assert_node == NULL_REF {
560                    continue;
561                }
562                let assert_expr = parse_condition_expr(arena, *assert_node, &self.parameters);
563                let assert_name = format!("__rule_assert_{}_{}", rule_name, idx);
564                self.conditions.insert(assert_name.clone(), assert_expr);
565
566                if let Some(ref ante) = antecedent {
567                    rule_implications.push(Implication { antecedent: ante.clone(), consequent: assert_name });
568                }
569            }
570        }
571
572        self.mutex_groups = extract_mutex_groups(&self.conditions);
573        self.implications = extract_implications(&self.conditions);
574        self.rule_implications = rule_implications;
575        self.referenced_param_values = OnceLock::new();
576    }
577
578    pub fn undefined_condition_refs(&self) -> Vec<(String, String)> {
579        let mut result = Vec::new();
580        for (owner, expr) in &self.conditions {
581            let mut refs = Vec::new();
582            collect_condition_refs(expr, &mut refs);
583            for r in refs {
584                if !self.conditions.contains_key(&r) {
585                    result.push((owner.clone(), r));
586                }
587            }
588        }
589        // The conditions map iterates in arbitrary order; sort so diagnostic
590        // emission order is deterministic across runs.
591        result.sort();
592        result
593    }
594
595    /// Names of top-level conditions whose body does not produce a boolean
596    /// (e.g. a bare `Fn::Ref`), for the not-a-boolean condition diagnostic.
597    /// Synthetic conditions (`__`-prefixed) are excluded - they are never
598    /// user-visible. Returned sorted for deterministic diagnostic ordering.
599    pub fn invalid_condition_bodies(&self) -> Vec<String> {
600        let mut names: Vec<String> = self
601            .conditions
602            .iter()
603            .filter(|(name, expr)| matches!(expr, ConditionExpr::Invalid) && !name.starts_with("__"))
604            .map(|(name, _)| name.clone())
605            .collect();
606        names.sort();
607        names
608    }
609
610    pub fn detect_cycles(&self) -> Vec<Vec<String>> {
611        let mut adj: HashMap<String, Vec<String>> = HashMap::new();
612        for (name, expr) in &self.conditions {
613            let mut refs = Vec::new();
614            collect_condition_refs(expr, &mut refs);
615            let mut targets: Vec<String> = refs.into_iter().filter(|r| self.conditions.contains_key(r)).collect();
616            targets.sort();
617            adj.insert(name.clone(), targets);
618        }
619
620        let mut sorted_names: Vec<&String> = self.conditions.keys().collect();
621        sorted_names.sort();
622
623        let mut cycles = Vec::new();
624        let mut visited: HashSet<String> = HashSet::new();
625        let mut on_stack: HashSet<String> = HashSet::new();
626        let mut path: Vec<String> = Vec::new();
627
628        for name in &sorted_names {
629            if !visited.contains(name.as_str()) {
630                Self::dfs_cycles(name, &adj, &mut visited, &mut on_stack, &mut path, &mut cycles);
631            }
632        }
633        cycles
634    }
635
636    fn dfs_cycles(
637        node: &str,
638        adj: &HashMap<String, Vec<String>>,
639        visited: &mut HashSet<String>,
640        on_stack: &mut HashSet<String>,
641        path: &mut Vec<String>,
642        cycles: &mut Vec<Vec<String>>,
643    ) {
644        visited.insert(node.to_string());
645        on_stack.insert(node.to_string());
646        path.push(node.to_string());
647
648        if let Some(neighbors) = adj.get(node) {
649            for neighbor in neighbors {
650                if !visited.contains(neighbor.as_str()) {
651                    Self::dfs_cycles(neighbor, adj, visited, on_stack, path, cycles);
652                } else if on_stack.contains(neighbor.as_str()) {
653                    let cycle_start = path.iter().position(|n| n == neighbor).unwrap();
654                    let cycle: Vec<String> = path[cycle_start..].to_vec();
655                    cycles.push(cycle);
656                }
657            }
658        }
659
660        path.pop();
661        on_stack.remove(node);
662    }
663
664    pub fn detect_equivalent_conditions(&self) -> Vec<(String, String)> {
665        let mut canonical_groups: HashMap<String, Vec<&str>> = HashMap::new();
666        for (name, expr) in &self.conditions {
667            if name.starts_with("__") {
668                continue;
669            }
670            if expr_has_opaque_value(expr) {
671                continue;
672            }
673            let canonical = canonical_form(expr);
674            canonical_groups.entry(canonical).or_default().push(name.as_str());
675        }
676
677        let mut pairs = Vec::new();
678        for (_, mut group) in canonical_groups {
679            if group.len() < 2 {
680                continue;
681            }
682            group.sort();
683            for other in &group[1..] {
684                pairs.push((group[0].to_string(), other.to_string()));
685            }
686        }
687        pairs
688    }
689}
690
691fn expr_has_opaque_value(expr: &ConditionExpr) -> bool {
692    match expr {
693        ConditionExpr::Equals(a, b) => value_is_opaque(a) || value_is_opaque(b),
694        ConditionExpr::And(items) | ConditionExpr::Or(items) => items.iter().any(expr_has_opaque_value),
695        ConditionExpr::Not(inner) => expr_has_opaque_value(inner),
696        // An invalid (non-boolean) body is reported by its own diagnostic and
697        // never participates in equivalence detection, so treat it as opaque to
698        // exclude it.
699        ConditionExpr::Invalid => true,
700        ConditionExpr::ConditionRef(_) => false,
701    }
702}
703
704fn value_is_opaque(val: &ValueExpr) -> bool {
705    match val {
706        ValueExpr::Other => true,
707        // A mapping lookup whose keys contain an opaque sub-expression cannot be
708        // compared for equivalence: two lookups that differ only in an opaque key
709        // (e.g. `Fn::Select` over different indices) would otherwise canonicalize
710        // to the same "?" and be wrongly reported as equivalent.
711        ValueExpr::MappingLookup { key1, key2, .. } => value_is_opaque(key1) || value_is_opaque(key2),
712        _ => false,
713    }
714}
715
716fn canonical_form(expr: &ConditionExpr) -> String {
717    match expr {
718        ConditionExpr::Equals(a, b) => {
719            let ca = canonical_value(a);
720            let cb = canonical_value(b);
721            let (left, right) = if ca <= cb { (ca, cb) } else { (cb, ca) };
722            format!("EQ({},{})", left, right)
723        }
724        ConditionExpr::And(items) => {
725            let mut parts: Vec<String> = items.iter().map(canonical_form).collect();
726            parts.sort();
727            format!("AND({})", parts.join(","))
728        }
729        ConditionExpr::Or(items) => {
730            let mut parts: Vec<String> = items.iter().map(canonical_form).collect();
731            parts.sort();
732            format!("OR({})", parts.join(","))
733        }
734        ConditionExpr::Not(inner) => {
735            format!("NOT({})", canonical_form(inner))
736        }
737        ConditionExpr::ConditionRef(name) => {
738            format!("CREF({})", name)
739        }
740        ConditionExpr::Invalid => "INVALID".to_string(),
741    }
742}
743
744fn canonical_value(val: &ValueExpr) -> String {
745    match val {
746        ValueExpr::ParamRef(name) => format!("P({})", name),
747        ValueExpr::Literal(s) => format!("L({})", s),
748        ValueExpr::PseudoParam(name) => format!("PP({})", name),
749        ValueExpr::MappingLookup { map_name, key1, key2 } => {
750            format!("MAP({},{},{})", map_name, canonical_value(key1), canonical_value(key2))
751        }
752        ValueExpr::Other => "?".to_string(),
753    }
754}
755
756fn collect_param_refs_from_expr(expr: &ConditionExpr, out: &mut Vec<String>) {
757    match expr {
758        ConditionExpr::Equals(a, b) => {
759            collect_param_refs_from_value(a, out);
760            collect_param_refs_from_value(b, out);
761        }
762        ConditionExpr::And(exprs) | ConditionExpr::Or(exprs) => {
763            for e in exprs {
764                collect_param_refs_from_expr(e, out);
765            }
766        }
767        ConditionExpr::Not(e) => collect_param_refs_from_expr(e, out),
768        ConditionExpr::ConditionRef(_) | ConditionExpr::Invalid => {}
769    }
770}
771
772fn collect_param_refs_from_value(expr: &ValueExpr, out: &mut Vec<String>) {
773    match expr {
774        ValueExpr::ParamRef(n) => out.push(n.clone()),
775        ValueExpr::MappingLookup { key1, key2, .. } => {
776            collect_param_refs_from_value(key1, out);
777            collect_param_refs_from_value(key2, out);
778        }
779        _ => {}
780    }
781}
782
783pub fn parse_condition_expr(
784    arena: &Arena,
785    node_ref: NodeRef,
786    parameters: &HashMap<String, ParameterInfo>,
787) -> ConditionExpr {
788    match arena.node(node_ref) {
789        Node::Intrinsic(IntrinsicFn::Equals(a, b)) => {
790            let va = parse_value_expr(arena, *a, parameters);
791            let vb = parse_value_expr(arena, *b, parameters);
792            ConditionExpr::Equals(va, vb)
793        }
794        Node::Intrinsic(IntrinsicFn::And(children)) => {
795            let exprs = children.iter().map(|c| parse_condition_expr(arena, *c, parameters)).collect();
796            ConditionExpr::And(exprs)
797        }
798        Node::Intrinsic(IntrinsicFn::Or(children)) => {
799            let exprs = children.iter().map(|c| parse_condition_expr(arena, *c, parameters)).collect();
800            ConditionExpr::Or(exprs)
801        }
802        Node::Intrinsic(IntrinsicFn::Not(child)) => {
803            let expr = parse_condition_expr(arena, *child, parameters);
804            ConditionExpr::Not(Box::new(expr))
805        }
806        Node::Intrinsic(IntrinsicFn::Ref(target)) => {
807            if let Some(name) = target.strip_prefix(CONDITION_REF_PREFIX) {
808                ConditionExpr::ConditionRef(name.to_string())
809            } else {
810                // A bare `Fn::Ref` (to a parameter, resource, or pseudo-parameter)
811                // is not a boolean and is not a condition reference, so it is an
812                // invalid condition body.
813                ConditionExpr::Invalid
814            }
815        }
816        _ => {
817            // Try to parse as a map with intrinsic keys
818            if let Some(entries) = arena.as_map(node_ref)
819                && entries.len() == 1
820            {
821                let (key, val) = &entries[0];
822                match key.as_str() {
823                    FN_EQUALS => {
824                        if let Some(arr) = arena.as_list(*val)
825                            && arr.len() == 2
826                        {
827                            let va = parse_value_expr(arena, arr[0], parameters);
828                            let vb = parse_value_expr(arena, arr[1], parameters);
829                            return ConditionExpr::Equals(va, vb);
830                        }
831                    }
832                    FN_AND => {
833                        if let Some(arr) = arena.as_list(*val) {
834                            let exprs = arr.iter().map(|c| parse_condition_expr(arena, *c, parameters)).collect();
835                            return ConditionExpr::And(exprs);
836                        }
837                    }
838                    FN_OR => {
839                        if let Some(arr) = arena.as_list(*val) {
840                            let exprs = arr.iter().map(|c| parse_condition_expr(arena, *c, parameters)).collect();
841                            return ConditionExpr::Or(exprs);
842                        }
843                    }
844                    FN_NOT => {
845                        if let Some(arr) = arena.as_list(*val)
846                            && !arr.is_empty()
847                        {
848                            let expr = parse_condition_expr(arena, arr[0], parameters);
849                            return ConditionExpr::Not(Box::new(expr));
850                        }
851                    }
852                    FN_CONDITION => {
853                        if let Some(name) = arena.as_str(*val) {
854                            return ConditionExpr::ConditionRef(name.to_string());
855                        }
856                    }
857                    _ => {}
858                }
859            }
860            ConditionExpr::Invalid
861        }
862    }
863}
864
865fn parse_value_expr(arena: &Arena, node_ref: NodeRef, parameters: &HashMap<String, ParameterInfo>) -> ValueExpr {
866    match arena.node(node_ref) {
867        Node::String(s) => ValueExpr::Literal(s.clone()),
868        Node::Int(i) => ValueExpr::Literal(i.to_string()),
869        Node::Bool(b) => ValueExpr::Literal(b.to_string()),
870        Node::Intrinsic(IntrinsicFn::Ref(target)) => {
871            if target.starts_with(PSEUDO_PREFIX) {
872                ValueExpr::PseudoParam(target.clone())
873            } else if parameters.contains_key(target) {
874                ValueExpr::ParamRef(target.clone())
875            } else {
876                ValueExpr::Other
877            }
878        }
879        // FindInMap can resolve to a concrete value, so it gets a dedicated
880        // variant the SAT solver understands. Every other intrinsic produces
881        // a value that cannot be known statically - it must be treated as an
882        // opaque unknown (`Other`), never a comparable literal. Treating, say,
883        // `Fn::Sub(...)` as the literal string "Sub(...)" would make
884        // `Fn::Equals[!Sub ..., "x"]` look like a literal-vs-literal compare and
885        // spuriously fire the always-true/false tautology check.
886        Node::Intrinsic(IntrinsicFn::FindInMap(m, k1, k2, _)) => {
887            let map_name = arena.as_str(*m).unwrap_or("?").to_string();
888            let key1 = parse_value_expr(arena, *k1, parameters);
889            let key2 = parse_value_expr(arena, *k2, parameters);
890            ValueExpr::MappingLookup { map_name, key1: Box::new(key1), key2: Box::new(key2) }
891        }
892        _ => ValueExpr::Other,
893    }
894}
895
896/// Walks a condition expression and collects, per parameter or pseudo-parameter
897/// it references, every literal it is compared against in an `Fn::Equals`.
898/// Pseudo-parameters (`AWS::Partition`, `AWS::Region`, …) are collected the same
899/// way as user parameters; the SAT solver treats both as named symbols whose
900/// candidate values are enumerated during satisfiability search.
901fn collect_equals_pairs(expr: &ConditionExpr, out: &mut HashMap<String, Vec<String>>) {
902    match expr {
903        ConditionExpr::Equals(a, b) => {
904            if let Some((symbol, literal)) = match_symbol_literal_pair(a, b) {
905                out.entry(symbol).or_default().push(literal);
906            } else {
907                collect_param_refs_from_value_into_pairs(a, out);
908                collect_param_refs_from_value_into_pairs(b, out);
909            }
910        }
911        ConditionExpr::And(exprs) | ConditionExpr::Or(exprs) => {
912            for e in exprs {
913                collect_equals_pairs(e, out);
914            }
915        }
916        ConditionExpr::Not(e) => collect_equals_pairs(e, out),
917        ConditionExpr::ConditionRef(_) | ConditionExpr::Invalid => {}
918    }
919}
920
921/// Returns the `(symbol_name, compared_literal)` when `a` and `b` are an
922/// `Fn::Equals` pair of a parameter (or pseudo-parameter) and a literal in
923/// either order. Returns `None` otherwise.
924fn match_symbol_literal_pair(a: &ValueExpr, b: &ValueExpr) -> Option<(String, String)> {
925    match (a, b) {
926        (ValueExpr::ParamRef(p), ValueExpr::Literal(v)) | (ValueExpr::Literal(v), ValueExpr::ParamRef(p)) => {
927            Some((p.clone(), v.clone()))
928        }
929        (ValueExpr::PseudoParam(p), ValueExpr::Literal(v)) | (ValueExpr::Literal(v), ValueExpr::PseudoParam(p)) => {
930            Some((p.clone(), v.clone()))
931        }
932        _ => None,
933    }
934}
935
936fn collect_param_refs_from_value_into_pairs(expr: &ValueExpr, out: &mut HashMap<String, Vec<String>>) {
937    match expr {
938        ValueExpr::ParamRef(p) | ValueExpr::PseudoParam(p) => {
939            out.entry(p.clone()).or_default();
940        }
941        ValueExpr::MappingLookup { key1, key2, .. } => {
942            collect_param_refs_from_value_into_pairs(key1, out);
943            collect_param_refs_from_value_into_pairs(key2, out);
944        }
945        _ => {}
946    }
947}
948
949fn collect_condition_refs(expr: &ConditionExpr, out: &mut Vec<String>) {
950    let mut borrowed = Vec::new();
951    condition_ref_names(expr, &mut borrowed);
952    out.extend(borrowed.into_iter().map(str::to_string));
953}
954
955pub fn collect_condition_deps(expr: &ConditionExpr, out: &mut Vec<String>) {
956    collect_condition_refs(expr, out);
957}
958
959fn extract_mutex_groups(conditions: &HashMap<String, ConditionExpr>) -> Vec<MutexGroup> {
960    // Find conditions that test the same parameter with different literal values.
961    // Handles both Equals(Param, Lit) and Not(Equals(Param, Lit)).
962    let mut param_tests: HashMap<String, Vec<(String, String)>> = HashMap::new(); // param → [(cond_name, literal)]
963
964    for (name, expr) in conditions {
965        if let Some((param, _lit, is_positive)) = extract_equals_test(expr) {
966            // Only positive tests (Equals) form mutex groups - two conditions
967            // testing Equals(Param, "X") and Equals(Param, "Y") are mutex.
968            // Not(Equals(Param, "X")) is compatible with Equals(Param, "Y").
969            if is_positive {
970                param_tests.entry(param).or_default().push((name.clone(), _lit));
971            }
972        }
973    }
974
975    param_tests
976        .into_iter()
977        .filter(|(_, tests)| tests.len() > 1)
978        .map(|(param, tests)| {
979            let conditions = tests.iter().map(|(n, _)| n.clone()).collect();
980            let values = tests.iter().map(|(_, v)| v.clone()).collect();
981            MutexGroup { conditions, parameter: param, values }
982        })
983        .collect()
984}
985
986fn extract_equals_test(expr: &ConditionExpr) -> Option<(String, String, bool)> {
987    match expr {
988        ConditionExpr::Equals(a, b) => {
989            if let (ValueExpr::ParamRef(p), ValueExpr::Literal(v)) | (ValueExpr::Literal(v), ValueExpr::ParamRef(p)) =
990                (a, b)
991            {
992                return Some((p.clone(), v.clone(), true));
993            }
994            None
995        }
996        ConditionExpr::Not(inner) => {
997            if let Some((param, lit, positive)) = extract_equals_test(inner) {
998                Some((param, lit, !positive))
999            } else {
1000                None
1001            }
1002        }
1003        _ => None,
1004    }
1005}
1006
1007/// One satisfiability query, prepared for enumeration.
1008///
1009/// A CloudFormation condition is a pure function of the template's parameters,
1010/// pseudo-parameters, and mappings - a condition has no truth value of its own
1011/// to choose. So "can these conditions hold simultaneously?" is decided over the
1012/// *parameter* space: the assumptions hold exactly when some assignment of
1013/// concrete values to the parameters they depend on makes every assumed
1014/// condition take its assumed value.
1015///
1016/// Searching parameter assignments rather than condition assignments is what
1017/// keeps the cost proportional to a template's real degrees of freedom. The
1018/// number of parameter points is the product of a few candidate values per
1019/// referenced parameter, while the space of condition truth assignments is
1020/// exponential in the number of conditions - so a template that layers many
1021/// conditions over a few shared inputs (a partition or region check reused
1022/// across dozens of conditions) stays cheap no matter how many conditions are
1023/// built on top of those inputs.
1024///
1025/// Enumerating points also removes the need to enforce derived constraints
1026/// separately: a parameter takes exactly one value at a point, so mutually
1027/// exclusive conditions cannot both hold there, and evaluating a condition at a
1028/// point yields exactly the value its `And`/`Or`/`Not` structure entails. Only
1029/// Rules-section constraints, which no condition expression entails, are applied
1030/// as a filter on admissible points.
1031struct SatisfiabilityQuery<'model> {
1032    model: &'model ConditionModel,
1033    /// Conditions whose value this query depends on: the assumed conditions, the
1034    /// conditions reachable from them through condition references, and the
1035    /// endpoints of every applicable Rules-section constraint. Positions in this
1036    /// list index `value_at_point` and `being_evaluated`.
1037    dependencies: Vec<&'model str>,
1038    position_of_dependency: HashMap<&'model str, usize>,
1039    /// Parameters the dependencies reference, each with the candidate values to
1040    /// enumerate. Held in a deterministic order so the work charged to the
1041    /// budget - and therefore which query first trips an exhausted budget - does
1042    /// not depend on hash iteration order.
1043    parameters: Vec<(&'model str, &'model [String])>,
1044    position_of_parameter: HashMap<&'model str, usize>,
1045    /// Which candidate value each parameter takes at the current point.
1046    selected_value: Vec<usize>,
1047    /// How many parameters are bound while the descent explores points: those at
1048    /// positions below it have a value, those at or above it are still free and
1049    /// read as undetermined.
1050    bound_depth: usize,
1051    /// Rules-section constraints to honor, as `(antecedent, consequent)` pairs of
1052    /// dependency positions.
1053    rule_constraints: Vec<(usize, usize)>,
1054    value_at_point: Vec<PointValue>,
1055    /// The value a condition is forced to take by the assumptions, for conditions
1056    /// the point itself leaves undetermined. Such a condition acts as a free
1057    /// choice - its value turns on data the model cannot resolve - and the
1058    /// assumptions constrain that choice: assuming `And(a, b)` holds forces both
1059    /// `a` and `b` to hold however little is known about them. Cleared for every
1060    /// point.
1061    forced_value: Vec<Option<bool>>,
1062    /// How many values the assumptions have forced. Propagation repeats while this
1063    /// keeps growing, so a value forced late still constrains what was examined
1064    /// earlier.
1065    forcings_made: u64,
1066    being_evaluated: Vec<bool>,
1067    /// How many reads returned something other than a property of the point alone:
1068    /// a reference cut because it closes a cycle, or a value the assumptions forced.
1069    /// Comparing this count before and after an evaluation is how that evaluation
1070    /// detects its result must not be cached as the point's own value.
1071    provisional_reads: u64,
1072    /// Satisfiability work performed, charged to the model's per-query and
1073    /// cumulative budgets by the caller.
1074    steps: u64,
1075}
1076
1077/// Cached value of one dependency at the current parameter point.
1078#[derive(Clone, Copy, PartialEq, Eq)]
1079enum PointValue {
1080    NotEvaluated,
1081    Determined(bool),
1082    /// The point does not determine the condition: it compares a value the model
1083    /// cannot resolve, references a condition the template does not define, or
1084    /// sits on a condition-reference cycle.
1085    Undetermined,
1086}
1087
1088/// The precomputed inputs of one point enumeration, bundled so the recursive
1089/// descent carries a single reference rather than three slices.
1090struct PointEnumeration<'query> {
1091    /// The value each assumed condition must take, by dependency position.
1092    expectations: &'query [(usize, bool)],
1093    /// The parameters each dependency's value depends on.
1094    dependency_masks: &'query [u64],
1095    /// The parameters still free at each depth, so the condition values that read
1096    /// them can be discarded before a new value is tried.
1097    unbound_masks: &'query [u64],
1098}
1099
1100impl<'model> SatisfiabilityQuery<'model> {
1101    fn prepare(
1102        model: &'model ConditionModel,
1103        assumed: &HashMap<&'model str, bool>,
1104        param_overrides: &'model HashMap<String, Vec<String>>,
1105    ) -> Self {
1106        let mut assumed_names: Vec<&'model str> = assumed.keys().copied().collect();
1107        assumed_names.sort_unstable();
1108
1109        let mut query = SatisfiabilityQuery {
1110            model,
1111            dependencies: Vec::new(),
1112            position_of_dependency: HashMap::new(),
1113            parameters: Vec::new(),
1114            position_of_parameter: HashMap::new(),
1115            selected_value: Vec::new(),
1116            bound_depth: 0,
1117            rule_constraints: Vec::new(),
1118            value_at_point: Vec::new(),
1119            forced_value: Vec::new(),
1120            forcings_made: 0,
1121            being_evaluated: Vec::new(),
1122            provisional_reads: 0,
1123            steps: 0,
1124        };
1125        query.add_dependencies_reachable_from(&assumed_names);
1126        query.add_parameters_of_dependencies(param_overrides);
1127        query.add_applicable_rule_constraints(param_overrides);
1128        query.order_parameters_by_reader_count();
1129        query.selected_value = vec![0; query.parameters.len()];
1130        query.value_at_point = vec![PointValue::NotEvaluated; query.dependencies.len()];
1131        query.forced_value = vec![None; query.dependencies.len()];
1132        query.being_evaluated = vec![false; query.dependencies.len()];
1133        query
1134    }
1135
1136    /// Orders the parameters for the descent: those pinned to a single candidate
1137    /// value first, since binding them can never branch, and the rest so that the
1138    /// one the fewest conditions read is bound last. The descent re-evaluates only
1139    /// conditions that read a parameter it just rebound, so varying the least-read
1140    /// parameter innermost keeps the largest number of condition values valid.
1141    fn order_parameters_by_reader_count(&mut self) {
1142        if self.parameters.len() < 2 {
1143            return;
1144        }
1145        let model = self.model;
1146        let mut reader_count: HashMap<&'model str, usize> = HashMap::new();
1147        let mut names: Vec<&'model str> = Vec::new();
1148        for dependency in &self.dependencies {
1149            let Some(expr) = model.conditions.get(*dependency) else {
1150                continue;
1151            };
1152            names.clear();
1153            parameter_names(expr, &mut names);
1154            names.sort_unstable();
1155            names.dedup();
1156            for name in &names {
1157                *reader_count.entry(name).or_default() += 1;
1158            }
1159        }
1160        self.steps += self.dependencies.len() as u64;
1161        // Ties break on the parameter name so the enumeration order - and with it
1162        // the work charged before a budget trips - never depends on hash
1163        // iteration order.
1164        self.parameters.sort_by(|(left, left_candidates), (right, right_candidates)| {
1165            (left_candidates.len() > 1)
1166                .cmp(&(right_candidates.len() > 1))
1167                .then_with(|| {
1168                    let left_readers = reader_count.get(left).copied().unwrap_or_default();
1169                    let right_readers = reader_count.get(right).copied().unwrap_or_default();
1170                    right_readers.cmp(&left_readers)
1171                })
1172                .then_with(|| left.cmp(right))
1173        });
1174        self.position_of_parameter = self.parameters.iter().enumerate().map(|(at, (name, _))| (*name, at)).collect();
1175    }
1176
1177    /// Adds `seeds` and every condition reachable from them through condition
1178    /// references. Reachability is what makes the parameter search exact: a
1179    /// condition no assumed condition can reach cannot influence the answer, and
1180    /// a parameter only that condition reads is therefore irrelevant too.
1181    fn add_dependencies_reachable_from(&mut self, seeds: &[&'model str]) {
1182        let conditions = &self.model.conditions;
1183        let mut pending: Vec<&'model str> = seeds.to_vec();
1184        while let Some(name) = pending.pop() {
1185            let Some((dependency, expr)) = conditions.get_key_value(name) else {
1186                continue;
1187            };
1188            let dependency = dependency.as_str();
1189            if self.position_of_dependency.contains_key(dependency) {
1190                continue;
1191            }
1192            self.position_of_dependency.insert(dependency, self.dependencies.len());
1193            self.dependencies.push(dependency);
1194            self.steps += 1;
1195            condition_ref_names(expr, &mut pending);
1196        }
1197    }
1198
1199    /// Registers the parameters the current dependencies read, with the candidate
1200    /// values to enumerate for each.
1201    fn add_parameters_of_dependencies(&mut self, param_overrides: &'model HashMap<String, Vec<String>>) {
1202        let conditions = &self.model.conditions;
1203        let mut names: Vec<&'model str> = Vec::new();
1204        for dependency in &self.dependencies {
1205            if let Some(expr) = conditions.get(*dependency) {
1206                parameter_names(expr, &mut names);
1207            }
1208        }
1209        names.sort_unstable();
1210        names.dedup();
1211        for name in names {
1212            self.add_parameter(name, param_overrides);
1213        }
1214    }
1215
1216    /// Registers one parameter and the values the query will try for it. A
1217    /// per-query override (e.g. a pinned target region) replaces the model's
1218    /// derived candidates. A parameter with no candidate values is left out: it
1219    /// is then undetermined at every point, which keeps the query conservative
1220    /// rather than silently making every condition that reads it unsatisfiable.
1221    fn add_parameter(&mut self, name: &'model str, param_overrides: &'model HashMap<String, Vec<String>>) {
1222        if self.position_of_parameter.contains_key(name) {
1223            return;
1224        }
1225        let candidates = param_overrides
1226            .get(name)
1227            .or_else(|| self.model.referenced_param_values().get(name))
1228            .map_or(&[][..], Vec::as_slice);
1229        if candidates.is_empty() {
1230            return;
1231        }
1232        self.position_of_parameter.insert(name, self.parameters.len());
1233        self.parameters.push((name, candidates));
1234        self.steps += 1;
1235    }
1236
1237    /// Adds the Rules-section constraints this query must honor. A Rules
1238    /// assertion restricts which parameter values a deployment can supply, and no
1239    /// condition expression entails it, so a point that violates one must be
1240    /// excluded rather than accepted.
1241    ///
1242    /// A constraint applies once it reads a parameter this query already varies;
1243    /// pulling it in can introduce further parameters, which can bring in further
1244    /// constraints, so this runs to a fixpoint. A constraint over parameters the
1245    /// query does not vary is left unenforced - its antecedent is undetermined at
1246    /// every point, so enforcing it could only reject points the template allows.
1247    fn add_applicable_rule_constraints(&mut self, param_overrides: &'model HashMap<String, Vec<String>>) {
1248        let model = self.model;
1249        if model.rule_implications.is_empty() {
1250            return;
1251        }
1252        let mut applied = vec![false; model.rule_implications.len()];
1253        loop {
1254            let mut newly_applied = false;
1255            for (position, implication) in model.rule_implications.iter().enumerate() {
1256                if applied[position] {
1257                    continue;
1258                }
1259                let (Some((antecedent, _)), Some((consequent, _))) = (
1260                    model.conditions.get_key_value(implication.antecedent.as_str()),
1261                    model.conditions.get_key_value(implication.consequent.as_str()),
1262                ) else {
1263                    applied[position] = true;
1264                    continue;
1265                };
1266                let endpoints = [antecedent.as_str(), consequent.as_str()];
1267                let mut steps = 0;
1268                let constrained_parameters = reachable_parameter_names(&model.conditions, &endpoints, &mut steps);
1269                self.steps += steps;
1270                if !constrained_parameters.iter().any(|name| self.position_of_parameter.contains_key(name)) {
1271                    continue;
1272                }
1273                applied[position] = true;
1274                newly_applied = true;
1275                self.add_dependencies_reachable_from(&endpoints);
1276                self.add_parameters_of_dependencies(param_overrides);
1277                self.rule_constraints
1278                    .push((self.position_of_dependency[endpoints[0]], self.position_of_dependency[endpoints[1]]));
1279            }
1280            if !newly_applied {
1281                return;
1282            }
1283        }
1284    }
1285
1286    /// How many distinct parameter points this query would enumerate.
1287    fn parameter_point_count(&self) -> u64 {
1288        self.parameters.iter().fold(1u64, |points, (_, candidates)| points.saturating_mul(candidates.len() as u64))
1289    }
1290
1291    /// Whether some parameter point makes every assumed condition take its
1292    /// assumed value.
1293    ///
1294    /// Parameters are bound one at a time and the descent continues only while the
1295    /// assumptions can still hold: a condition the parameters bound so far already
1296    /// decide against its assumption cannot be rescued by the parameters still
1297    /// unbound, so every point below that branch is skipped. That is what keeps a
1298    /// wide parameter space affordable - the branches that could satisfy the query
1299    /// are explored, the rest are cut at the level that decided them.
1300    fn some_point_satisfies(&mut self, assumed: &HashMap<&'model str, bool>) -> bool {
1301        let mut expectations: Vec<(usize, bool)> = assumed
1302            .iter()
1303            .filter_map(|(name, expected)| self.position_of_dependency.get(name).map(|&at| (at, *expected)))
1304            .collect();
1305        expectations.sort_unstable();
1306        let parameter_masks = self.varying_parameter_masks();
1307        let dependency_masks = self.dependency_parameter_masks(&parameter_masks);
1308        let mut unbound_masks = vec![0u64; self.parameters.len() + 1];
1309        for at in (0..self.parameters.len()).rev() {
1310            unbound_masks[at] = unbound_masks[at + 1] | parameter_masks[at];
1311        }
1312        // Parameters pinned to a single candidate value are ordered first and are
1313        // bound for the whole enumeration: their value cannot vary, so descending
1314        // through them would only deepen the recursion without branching.
1315        self.bound_depth = self.parameters.iter().take_while(|(_, candidates)| candidates.len() < 2).count();
1316        let enumeration = PointEnumeration {
1317            expectations: &expectations,
1318            dependency_masks: &dependency_masks,
1319            unbound_masks: &unbound_masks,
1320        };
1321        self.extend_point(&enumeration)
1322    }
1323
1324    /// Explores the points that agree with the parameters bound so far, returning
1325    /// whether any of them satisfies the query.
1326    fn extend_point(&mut self, enumeration: &PointEnumeration<'_>) -> bool {
1327        self.steps += 1;
1328        if self.steps > MAX_SAT_ITERATIONS {
1329            // The number of points is the product of the candidate values of every
1330            // parameter the query reads, so it grows exponentially in that
1331            // parameter count. Once the per-query budget is spent, assume
1332            // satisfiable rather than explore further - the conservative-`true`
1333            // contract documented on `is_satisfiable`.
1334            return true;
1335        }
1336        if !self.assumptions_can_still_hold(enumeration.expectations) {
1337            return false;
1338        }
1339        let at = self.bound_depth;
1340        if at == self.parameters.len() {
1341            return true;
1342        }
1343        for candidate in 0..self.parameters[at].1.len() {
1344            self.selected_value[at] = candidate;
1345            // Everything from this parameter inward is about to take a new value,
1346            // so the condition values that read any of them no longer hold.
1347            self.discard_values_affected_by(enumeration.unbound_masks[at], enumeration.dependency_masks);
1348            self.bound_depth = at + 1;
1349            let satisfied = self.extend_point(enumeration);
1350            self.bound_depth = at;
1351            if satisfied {
1352                return true;
1353            }
1354        }
1355        false
1356    }
1357
1358    /// Whether the assumptions and the applicable Rules-section constraints can
1359    /// still hold given the parameters bound so far. A condition that is already
1360    /// determined against its assumption stays that way however the unbound
1361    /// parameters are chosen, which is what makes cutting the branch sound.
1362    ///
1363    /// A condition the parameters leave undetermined is not simply accepted:
1364    /// assuming it holds also constrains the conditions it is built from, so the
1365    /// assumptions are propagated into those and any contradiction they force is a
1366    /// contradiction of the assumptions. Propagation repeats while it keeps forcing
1367    /// new values, so a value forced late still contradicts an assumption examined
1368    /// earlier. Only contradictions that must hold are derived - where two operands
1369    /// could each be the one satisfying a disjunction, nothing is forced - so the
1370    /// answer degrades to the conservative "can still hold" rather than to a wrong
1371    /// rejection.
1372    fn assumptions_can_still_hold(&mut self, expectations: &[(usize, bool)]) -> bool {
1373        self.forced_value.fill(None);
1374        loop {
1375            let forcings_before = self.forcings_made;
1376            for &(dependency, expected) in expectations {
1377                if !self.require_dependency(dependency, expected) {
1378                    return false;
1379                }
1380            }
1381            if !self.rule_constraints_hold() {
1382                return false;
1383            }
1384            if self.forcings_made == forcings_before {
1385                return true;
1386            }
1387        }
1388    }
1389
1390    /// Requires a dependency to take a value, propagating into the conditions it is
1391    /// built from when the point leaves it undetermined. Returns `false` when that
1392    /// contradicts what the point determines or what the assumptions already forced.
1393    fn require_dependency(&mut self, dependency: usize, expected: bool) -> bool {
1394        self.steps += 1;
1395        if let Some(determined) = self.dependency_value(dependency) {
1396            return determined == expected;
1397        }
1398        match self.forced_value[dependency] {
1399            Some(already_forced) => return already_forced == expected,
1400            None => {
1401                self.forced_value[dependency] = Some(expected);
1402                self.forcings_made += 1;
1403            }
1404        }
1405        let Some(expr) = self.model.conditions.get(self.dependencies[dependency]) else {
1406            return true;
1407        };
1408        self.require(expr, expected)
1409    }
1410
1411    /// Requires an expression to take a value, deriving the values its operands
1412    /// must take. A conjunction that must hold requires every operand to hold, a
1413    /// disjunction that must not hold requires every operand not to hold, and a
1414    /// negation inverts the requirement; the remaining shapes need at least one
1415    /// operand to comply, which forces nothing until only one candidate is left.
1416    fn require(&mut self, expr: &'model ConditionExpr, expected: bool) -> bool {
1417        self.steps += 1;
1418        match expr {
1419            ConditionExpr::ConditionRef(name) => match self.position_of_dependency.get(name.as_str()).copied() {
1420                Some(dependency) => self.require_dependency(dependency, expected),
1421                // A reference the template does not define constrains nothing.
1422                None => true,
1423            },
1424            ConditionExpr::Not(operand) => self.require(operand, !expected),
1425            ConditionExpr::And(operands) if expected => operands.iter().all(|operand| self.require(operand, true)),
1426            ConditionExpr::Or(operands) if !expected => operands.iter().all(|operand| self.require(operand, false)),
1427            ConditionExpr::And(operands) => self.require_any(operands, false),
1428            ConditionExpr::Or(operands) => self.require_any(operands, true),
1429            // A comparison is a leaf: when the point cannot decide it, the values it
1430            // reads are simply unknown, and any requirement on it can be met.
1431            ConditionExpr::Equals(_, _) | ConditionExpr::Invalid => true,
1432        }
1433    }
1434
1435    /// Requires at least one operand to take `satisfying`. Nothing is forced while
1436    /// more than one operand could be the one that does - only the two certainties
1437    /// are derived: a contradiction when every operand already rules it out, and the
1438    /// value of the last operand that could still comply.
1439    fn require_any(&mut self, operands: &'model [ConditionExpr], satisfying: bool) -> bool {
1440        let mut undecided: Option<&'model ConditionExpr> = None;
1441        let mut undecided_operands = 0usize;
1442        for operand in operands {
1443            match self.evaluate(operand) {
1444                Some(value) if value == satisfying => return true,
1445                Some(_) => {}
1446                None => {
1447                    undecided_operands += 1;
1448                    undecided = Some(operand);
1449                }
1450            }
1451        }
1452        match (undecided_operands, undecided) {
1453            (0, _) => false,
1454            (1, Some(last_candidate)) => self.require(last_candidate, satisfying),
1455            _ => true,
1456        }
1457    }
1458
1459    /// Whether the applicable Rules-section constraints hold, forcing what they
1460    /// entail: a constraint whose antecedent holds requires its consequent to hold,
1461    /// and one whose consequent cannot hold requires its antecedent not to.
1462    fn rule_constraints_hold(&mut self) -> bool {
1463        for at in 0..self.rule_constraints.len() {
1464            let (antecedent, consequent) = self.rule_constraints[at];
1465            if self.dependency_belief(antecedent) == Some(true) && !self.require_dependency(consequent, true) {
1466                return false;
1467            }
1468            if self.dependency_belief(consequent) == Some(false) && !self.require_dependency(antecedent, false) {
1469                return false;
1470            }
1471        }
1472        true
1473    }
1474
1475    /// One bit per parameter whose value varies across points. A parameter with a
1476    /// single candidate value gets an empty mask because its value never changes.
1477    /// Parameters past the width of the mask share its last bit, which
1478    /// over-invalidates rather than under-invalidates.
1479    fn varying_parameter_masks(&self) -> Vec<u64> {
1480        let mut masks = Vec::with_capacity(self.parameters.len());
1481        let mut next_bit = 0u32;
1482        for (_, candidates) in &self.parameters {
1483            if candidates.len() < 2 {
1484                masks.push(0);
1485                continue;
1486            }
1487            masks.push(1u64 << next_bit);
1488            next_bit = (next_bit + 1).min(u64::BITS - 1);
1489        }
1490        masks
1491    }
1492
1493    /// For each dependency, the parameters its value can depend on: those it reads
1494    /// itself and those read by everything it references. Enumeration consults
1495    /// this to keep the values a parameter change cannot have affected, which is
1496    /// what makes the cost of a point proportional to the conditions that
1497    /// actually moved rather than to the whole dependency closure.
1498    fn dependency_parameter_masks(&mut self, parameter_masks: &[u64]) -> Vec<u64> {
1499        let model = self.model;
1500        let mut masks = vec![0u64; self.dependencies.len()];
1501        let mut references: Vec<Vec<usize>> = vec![Vec::new(); self.dependencies.len()];
1502        let mut names: Vec<&'model str> = Vec::new();
1503        let mut referenced: Vec<&'model str> = Vec::new();
1504        for (at, dependency) in self.dependencies.iter().enumerate() {
1505            let Some(expr) = model.conditions.get(*dependency) else {
1506                continue;
1507            };
1508            names.clear();
1509            parameter_names(expr, &mut names);
1510            for name in &names {
1511                if let Some(&position) = self.position_of_parameter.get(*name) {
1512                    masks[at] |= parameter_masks[position];
1513                }
1514            }
1515            referenced.clear();
1516            condition_ref_names(expr, &mut referenced);
1517            references[at] =
1518                referenced.iter().filter_map(|name| self.position_of_dependency.get(*name).copied()).collect();
1519        }
1520        self.steps += self.dependencies.len() as u64;
1521
1522        // Propagate to a fixpoint: masks only grow, so this terminates even when
1523        // the references form a cycle.
1524        loop {
1525            let mut grew = false;
1526            for at in 0..masks.len() {
1527                let propagated = references[at].iter().fold(masks[at], |mask, &reference| mask | masks[reference]);
1528                if propagated != masks[at] {
1529                    masks[at] = propagated;
1530                    grew = true;
1531                }
1532            }
1533            self.steps += masks.len() as u64;
1534            if !grew {
1535                return masks;
1536            }
1537        }
1538    }
1539
1540    /// Drops the cached values of every dependency that reads one of the
1541    /// parameters that just changed.
1542    fn discard_values_affected_by(&mut self, changed_parameters: u64, dependency_masks: &[u64]) {
1543        for (at, value) in self.value_at_point.iter_mut().enumerate() {
1544            if dependency_masks[at] & changed_parameters != 0 {
1545                *value = PointValue::NotEvaluated;
1546            }
1547        }
1548    }
1549
1550    /// The value of one dependency at the current point, memoized for the rest of
1551    /// this point's evaluation. Only what the point itself determines is cached;
1552    /// values that came from a cut cycle or from what the assumptions forced are
1553    /// left out, because they hold for one reference rather than for the point.
1554    fn dependency_value(&mut self, dependency: usize) -> Option<bool> {
1555        match self.value_at_point[dependency] {
1556            PointValue::Determined(value) => return Some(value),
1557            PointValue::Undetermined => return None,
1558            PointValue::NotEvaluated => {}
1559        }
1560        if self.being_evaluated[dependency] {
1561            // A condition that transitively references itself has no value to
1562            // read here; leaving this reference undetermined keeps evaluation
1563            // total and terminating. The cycle itself is reported as a template
1564            // defect by the reference-graph analysis.
1565            self.provisional_reads += 1;
1566            return None;
1567        }
1568        let Some(expr) = self.model.conditions.get(self.dependencies[dependency]) else {
1569            self.value_at_point[dependency] = PointValue::Undetermined;
1570            return None;
1571        };
1572        self.being_evaluated[dependency] = true;
1573        let provisional_reads_before = self.provisional_reads;
1574        let value = self.evaluate(expr);
1575        self.being_evaluated[dependency] = false;
1576        if self.provisional_reads == provisional_reads_before {
1577            self.value_at_point[dependency] = value.map_or(PointValue::Undetermined, PointValue::Determined);
1578        }
1579        value
1580    }
1581
1582    /// What is currently believed about a dependency: the value the point
1583    /// determines, or failing that the value the assumptions have forced on it.
1584    fn dependency_belief(&mut self, dependency: usize) -> Option<bool> {
1585        if let Some(determined) = self.dependency_value(dependency) {
1586            return Some(determined);
1587        }
1588        let forced = self.forced_value[dependency];
1589        if forced.is_some() {
1590            self.provisional_reads += 1;
1591        }
1592        forced
1593    }
1594
1595    /// Evaluates a condition expression at the current point. `None` means the
1596    /// point does not determine the expression; it propagates except where an
1597    /// operand already decides the result - a false operand of `And`, a true
1598    /// operand of `Or` - so an unresolvable comparison in one branch does not
1599    /// discard what the rest of the expression proves.
1600    fn evaluate(&mut self, expr: &'model ConditionExpr) -> Option<bool> {
1601        // Each evaluation step is one unit of satisfiability work. Counting here,
1602        // in the recursive core, keeps the budget proportional to real effort: a
1603        // deep condition closure draws the budget down faster than a shallow one,
1604        // so the budget bounds actual work uniformly regardless of how the
1605        // conditions are shaped.
1606        self.steps += 1;
1607        match expr {
1608            ConditionExpr::Equals(left, right) => {
1609                let left = self.value_at_current_point(left)?;
1610                let right = self.value_at_current_point(right)?;
1611                Some(left == right)
1612            }
1613            ConditionExpr::And(operands) => {
1614                let mut all_determined = true;
1615                for operand in operands {
1616                    match self.evaluate(operand) {
1617                        Some(false) => return Some(false),
1618                        Some(true) => {}
1619                        None => all_determined = false,
1620                    }
1621                }
1622                all_determined.then_some(true)
1623            }
1624            ConditionExpr::Or(operands) => {
1625                let mut all_determined = true;
1626                for operand in operands {
1627                    match self.evaluate(operand) {
1628                        Some(true) => return Some(true),
1629                        Some(false) => {}
1630                        None => all_determined = false,
1631                    }
1632                }
1633                all_determined.then_some(false)
1634            }
1635            ConditionExpr::Not(operand) => self.evaluate(operand).map(|value| !value),
1636            ConditionExpr::ConditionRef(name) => {
1637                // A reference to a condition the template does not define has no
1638                // value; it is reported as an undefined reference elsewhere.
1639                let dependency = self.position_of_dependency.get(name.as_str()).copied()?;
1640                self.dependency_belief(dependency)
1641            }
1642            // A condition body that is not a boolean has no truth value, so it
1643            // never decides a query.
1644            ConditionExpr::Invalid => None,
1645        }
1646    }
1647
1648    /// The concrete value of a value expression at the current point, or `None`
1649    /// when the point does not determine it.
1650    fn value_at_current_point(&mut self, expr: &'model ValueExpr) -> Option<String> {
1651        match expr {
1652            ValueExpr::Literal(literal) => Some(literal.clone()),
1653            ValueExpr::ParamRef(name) => self.selected_parameter_value(name).map(str::to_string),
1654            ValueExpr::PseudoParam(name) => self
1655                .selected_parameter_value(name)
1656                .map(str::to_string)
1657                .or_else(|| self.model.pseudo_overrides.fixed_value(name)),
1658            ValueExpr::MappingLookup { map_name, key1, key2 } => {
1659                let top_level_key = self.value_at_current_point(key1)?;
1660                let second_level_key = self.value_at_current_point(key2)?;
1661                let mapped = self.model.mappings.get(map_name)?.get(&top_level_key)?.get(&second_level_key)?;
1662                match mapped {
1663                    serde_json::Value::String(text) => Some(text.clone()),
1664                    other => Some(other.to_string()),
1665                }
1666            }
1667            ValueExpr::Other => None,
1668        }
1669    }
1670
1671    /// The value the parameters bound so far assign to a parameter, or `None` when
1672    /// the query does not vary it or has not bound it yet.
1673    fn selected_parameter_value(&self, name: &str) -> Option<&'model str> {
1674        let position = *self.position_of_parameter.get(name)?;
1675        if position >= self.bound_depth {
1676            return None;
1677        }
1678        self.parameters[position].1.get(self.selected_value[position]).map(String::as_str)
1679    }
1680}
1681
1682/// Names of the conditions `expr` references, borrowed from `expr` so the
1683/// satisfiability hot path collects them without allocating.
1684fn condition_ref_names<'expr>(expr: &'expr ConditionExpr, out: &mut Vec<&'expr str>) {
1685    match expr {
1686        ConditionExpr::ConditionRef(name) => out.push(name.as_str()),
1687        ConditionExpr::And(operands) | ConditionExpr::Or(operands) => {
1688            for operand in operands {
1689                condition_ref_names(operand, out);
1690            }
1691        }
1692        ConditionExpr::Not(operand) => condition_ref_names(operand, out),
1693        ConditionExpr::Equals(_, _) | ConditionExpr::Invalid => {}
1694    }
1695}
1696
1697/// Names of the parameters and pseudo-parameters `expr` reads directly - not
1698/// those read by the conditions it references.
1699fn parameter_names<'expr>(expr: &'expr ConditionExpr, out: &mut Vec<&'expr str>) {
1700    match expr {
1701        ConditionExpr::Equals(left, right) => {
1702            parameter_names_of_value(left, out);
1703            parameter_names_of_value(right, out);
1704        }
1705        ConditionExpr::And(operands) | ConditionExpr::Or(operands) => {
1706            for operand in operands {
1707                parameter_names(operand, out);
1708            }
1709        }
1710        ConditionExpr::Not(operand) => parameter_names(operand, out),
1711        ConditionExpr::ConditionRef(_) | ConditionExpr::Invalid => {}
1712    }
1713}
1714
1715fn parameter_names_of_value<'expr>(expr: &'expr ValueExpr, out: &mut Vec<&'expr str>) {
1716    match expr {
1717        ValueExpr::ParamRef(name) | ValueExpr::PseudoParam(name) => out.push(name.as_str()),
1718        ValueExpr::MappingLookup { key1, key2, .. } => {
1719            parameter_names_of_value(key1, out);
1720            parameter_names_of_value(key2, out);
1721        }
1722        ValueExpr::Literal(_) | ValueExpr::Other => {}
1723    }
1724}
1725
1726/// Names of the parameters and pseudo-parameters reachable from `seeds` through
1727/// condition references, with the traversal cost added to `steps`.
1728fn reachable_parameter_names<'conditions>(
1729    conditions: &'conditions HashMap<String, ConditionExpr>,
1730    seeds: &[&'conditions str],
1731    steps: &mut u64,
1732) -> Vec<&'conditions str> {
1733    let mut visited: HashSet<&'conditions str> = HashSet::new();
1734    let mut pending: Vec<&'conditions str> = seeds.to_vec();
1735    let mut names = Vec::new();
1736    while let Some(name) = pending.pop() {
1737        if !visited.insert(name) {
1738            continue;
1739        }
1740        *steps += 1;
1741        if let Some(expr) = conditions.get(name) {
1742            parameter_names(expr, &mut names);
1743            condition_ref_names(expr, &mut pending);
1744        }
1745    }
1746    names.sort_unstable();
1747    names.dedup();
1748    names
1749}
1750
1751/// Derives `antecedent => consequent` pairs from `And`/`Or` condition
1752/// structure. These are *enforced* by the satisfiability search, so only
1753/// logically sound pairs may be produced:
1754///
1755/// * `X = And(...)` implies each condition reference reachable through nested
1756///   `And`s (`And(And(A, B), C)` implies `A`, `B`, and `C`) - but nothing
1757///   under an `Or` or `Not` child, whose references are not individually
1758///   entailed.
1759/// * Symmetrically, each condition reference reachable through nested `Or`s
1760///   implies `X = Or(...)`, and nothing under an `And` or `Not` child does.
1761fn extract_implications(conditions: &HashMap<String, ConditionExpr>) -> Vec<Implication> {
1762    let mut implications = Vec::new();
1763
1764    for (name, expr) in conditions {
1765        match expr {
1766            ConditionExpr::And(children) => {
1767                let mut refs = Vec::new();
1768                collect_same_operator_refs(children, true, &mut refs);
1769                for ref_name in refs {
1770                    implications.push(Implication { antecedent: name.clone(), consequent: ref_name });
1771                }
1772            }
1773            ConditionExpr::Or(children) => {
1774                let mut refs = Vec::new();
1775                collect_same_operator_refs(children, false, &mut refs);
1776                for ref_name in refs {
1777                    implications.push(Implication { antecedent: ref_name, consequent: name.clone() });
1778                }
1779            }
1780            _ => {}
1781        }
1782    }
1783
1784    implications
1785}
1786
1787/// Collects condition references reachable through nested operators of the
1788/// *same* kind only (`in_and` selects which). Crossing into the other operator
1789/// or a `Not` breaks entailment, so those subtrees are skipped.
1790fn collect_same_operator_refs(exprs: &[ConditionExpr], in_and: bool, out: &mut Vec<String>) {
1791    for child in exprs {
1792        match child {
1793            ConditionExpr::ConditionRef(name) => out.push(name.clone()),
1794            ConditionExpr::And(children) if in_and => collect_same_operator_refs(children, in_and, out),
1795            ConditionExpr::Or(children) if !in_and => collect_same_operator_refs(children, in_and, out),
1796            _ => {}
1797        }
1798    }
1799}
1800
1801#[cfg(test)]
1802mod tests {
1803    use super::*;
1804    use crate::model::PseudoParameterOverrides;
1805    use crate::parser;
1806    use crate::resolver::{extract_mappings, extract_parameters};
1807    use std::fmt::Write;
1808
1809    fn build_condition_model(input: &str) -> ConditionModel {
1810        let ir = parser::parse(input.as_bytes()).unwrap();
1811        let (params, _) = extract_parameters(&ir);
1812        let (mappings, _) = extract_mappings(&ir);
1813        let pseudo = PseudoParameterOverrides::default();
1814        ConditionModel::from_ir(&ir, &params, &pseudo, &mappings)
1815    }
1816
1817    #[test]
1818    fn parse_simple_conditions() {
1819        let input = r#"
1820Parameters:
1821  Env:
1822    Type: String
1823    AllowedValues:
1824      - Prod
1825      - Dev
1826Conditions:
1827  isProduction:
1828    Fn::Equals:
1829      - !Ref Env
1830      - Prod
1831  isDevelopment:
1832    Fn::Equals:
1833      - !Ref Env
1834      - Dev
1835Resources:
1836  R:
1837    Type: T
1838"#;
1839        let model = build_condition_model(input);
1840        assert_eq!(model.conditions.len(), 2);
1841        assert!(model.conditions.contains_key("isProduction"));
1842        assert!(model.conditions.contains_key("isDevelopment"));
1843    }
1844
1845    #[test]
1846    fn mutex_group_extraction() {
1847        let input = r#"
1848Parameters:
1849  Env:
1850    Type: String
1851    AllowedValues: [Prod, Dev]
1852Conditions:
1853  isProduction:
1854    Fn::Equals: [!Ref Env, Prod]
1855  isDevelopment:
1856    Fn::Equals: [!Ref Env, Dev]
1857Resources:
1858  R:
1859    Type: T
1860"#;
1861        let model = build_condition_model(input);
1862        assert!(!model.mutex_groups.is_empty());
1863        assert_eq!(model.mutex_groups[0].conditions.len(), 2);
1864    }
1865
1866    #[test]
1867    fn satisfiable_single_true() {
1868        let input = r#"
1869Parameters:
1870  Env:
1871    Type: String
1872    AllowedValues: [Prod, Dev]
1873Conditions:
1874  isProduction:
1875    Fn::Equals: [!Ref Env, Prod]
1876  isDevelopment:
1877    Fn::Equals: [!Ref Env, Dev]
1878Resources:
1879  R:
1880    Type: T
1881"#;
1882        let model = build_condition_model(input);
1883        assert!(model.is_satisfiable(&[("isProduction".into(), true)]));
1884    }
1885
1886    #[test]
1887    fn satisfiable_mutex_violation() {
1888        let input = r#"
1889Parameters:
1890  Env:
1891    Type: String
1892    AllowedValues: [Prod, Dev]
1893Conditions:
1894  isProduction:
1895    Fn::Equals: [!Ref Env, Prod]
1896  isDevelopment:
1897    Fn::Equals: [!Ref Env, Dev]
1898Resources:
1899  R:
1900    Type: T
1901"#;
1902        let model = build_condition_model(input);
1903        assert!(!model.conditions_compatible("isProduction", "isDevelopment"));
1904    }
1905
1906    #[test]
1907    fn resources_compatible_both_unconditional() {
1908        let input = r#"
1909Resources:
1910  R:
1911    Type: T
1912"#;
1913        let model = build_condition_model(input);
1914        assert!(model.resources_compatible(None, None));
1915    }
1916
1917    #[test]
1918    fn no_conditions_template() {
1919        let input = r#"{"Resources":{"R":{"Type":"T"}}}"#;
1920        let model = build_condition_model(input);
1921        assert!(model.conditions.is_empty());
1922        assert!(model.is_satisfiable(&[]));
1923    }
1924
1925    #[test]
1926    fn condition_implies_same_condition() {
1927        let input = r#"
1928Parameters:
1929  Env:
1930    Type: String
1931    AllowedValues: [Prod, Dev]
1932Conditions:
1933  isProduction:
1934    Fn::Equals: [!Ref Env, Prod]
1935Resources:
1936  R:
1937    Type: T
1938"#;
1939        let model = build_condition_model(input);
1940        // A condition always implies itself
1941        assert!(model.condition_implies("isProduction", "isProduction"));
1942    }
1943
1944    #[test]
1945    fn is_satisfiable_contradictory_assumptions() {
1946        let input = r#"
1947Parameters:
1948  Env:
1949    Type: String
1950    AllowedValues: [Prod, Dev]
1951Conditions:
1952  isProduction:
1953    Fn::Equals: [!Ref Env, Prod]
1954Resources:
1955  R:
1956    Type: T
1957"#;
1958        let model = build_condition_model(input);
1959        // Same condition assumed both true and false is unsatisfiable
1960        assert!(!model.is_satisfiable(&[("isProduction".into(), true), ("isProduction".into(), false),]));
1961    }
1962
1963    #[test]
1964    fn condition_implies_mutex_conditions() {
1965        let input = r#"
1966Parameters:
1967  Env:
1968    Type: String
1969    AllowedValues: [Prod, Dev]
1970Conditions:
1971  isProduction:
1972    Fn::Equals: [!Ref Env, Prod]
1973  isDevelopment:
1974    Fn::Equals: [!Ref Env, Dev]
1975Resources:
1976  R:
1977    Type: T
1978"#;
1979        let model = build_condition_model(input);
1980        // Mutex conditions do not imply each other
1981        assert!(!model.condition_implies("isProduction", "isDevelopment"));
1982        assert!(!model.condition_implies("isDevelopment", "isProduction"));
1983    }
1984
1985    #[test]
1986    fn compatible_or_condition_without_allowed_values() {
1987        let input = r#"
1988Parameters:
1989  Env:
1990    Type: String
1991Conditions:
1992  IsProd:
1993    Fn::Equals: [!Ref Env, Prod]
1994  IsProdOrStage:
1995    Fn::Or:
1996      - Condition: IsProd
1997      - Fn::Equals: [!Ref Env, Stage]
1998Resources:
1999  R:
2000    Type: T
2001"#;
2002        let model = build_condition_model(input);
2003        assert!(model.conditions_compatible("IsProd", "IsProdOrStage"));
2004        assert!(model.condition_implies("IsProd", "IsProdOrStage"));
2005        assert!(!model.condition_implies("IsProdOrStage", "IsProd"));
2006    }
2007
2008    #[test]
2009    fn tautological_equals_literal_match() {
2010        let input = r#"
2011Conditions:
2012  AlwaysTrue:
2013    Fn::Equals: ["same", "same"]
2014  AlwaysFalse:
2015    Fn::Equals: ["a", "b"]
2016Resources:
2017  R:
2018    Type: T
2019"#;
2020        let model = build_condition_model(input);
2021        let taut = model.tautological_equals();
2022        assert!(taut.iter().any(|(n, v)| n == "AlwaysTrue" && *v));
2023        assert!(taut.iter().any(|(n, v)| n == "AlwaysFalse" && !*v));
2024    }
2025
2026    #[test]
2027    fn tautological_equals_nested_in_and() {
2028        let input = r#"
2029Parameters:
2030  Env:
2031    Type: String
2032Conditions:
2033  Nested:
2034    Fn::And:
2035      - Fn::Equals: ["x", "x"]
2036      - Fn::Equals: [!Ref Env, Prod]
2037Resources:
2038  R:
2039    Type: T
2040"#;
2041        let model = build_condition_model(input);
2042        let taut = model.tautological_equals();
2043        assert!(
2044            taut.iter().any(|(n, v)| n == "Nested" && *v),
2045            "should detect tautological Equals inside And: {:?}",
2046            taut
2047        );
2048    }
2049
2050    #[test]
2051    fn referenced_params_collected() {
2052        let input = r#"
2053Parameters:
2054  Env:
2055    Type: String
2056  Region:
2057    Type: String
2058Conditions:
2059  IsProd:
2060    Fn::Equals: [!Ref Env, Prod]
2061  IsUsEast:
2062    Fn::Equals: [!Ref Region, us-east-1]
2063Resources:
2064  R:
2065    Type: T
2066"#;
2067        let model = build_condition_model(input);
2068        let refs = model.referenced_params();
2069        assert!(refs.contains(&"Env".to_string()));
2070        assert!(refs.contains(&"Region".to_string()));
2071        assert_eq!(refs.len(), 2);
2072    }
2073
2074    #[test]
2075    fn and_implication_extracted() {
2076        let input = r#"
2077Parameters:
2078  Env:
2079    Type: String
2080  DB:
2081    Type: String
2082    AllowedValues: [yes, no]
2083Conditions:
2084  IsProd:
2085    Fn::Equals: [!Ref Env, Prod]
2086  CreateDB:
2087    Fn::Equals: [!Ref DB, yes]
2088  ProdAndDB:
2089    Fn::And:
2090      - Condition: IsProd
2091      - Condition: CreateDB
2092Resources:
2093  R:
2094    Type: T
2095"#;
2096        let model = build_condition_model(input);
2097        // ProdAndDB = And(IsProd, CreateDB) → ProdAndDB implies IsProd and CreateDB
2098        assert!(model.implications.iter().any(|i| i.antecedent == "ProdAndDB" && i.consequent == "IsProd"));
2099        assert!(model.implications.iter().any(|i| i.antecedent == "ProdAndDB" && i.consequent == "CreateDB"));
2100    }
2101
2102    #[test]
2103    fn or_implication_extracted() {
2104        let input = r#"
2105Parameters:
2106  Env:
2107    Type: String
2108    AllowedValues: [Prod, Dev, Stage]
2109Conditions:
2110  IsProd:
2111    Fn::Equals: [!Ref Env, Prod]
2112  IsDev:
2113    Fn::Equals: [!Ref Env, Dev]
2114  ProdOrDev:
2115    Fn::Or:
2116      - Condition: IsProd
2117      - Condition: IsDev
2118Resources:
2119  R:
2120    Type: T
2121"#;
2122        let model = build_condition_model(input);
2123        // Or(IsProd, IsDev) → IsProd implies ProdOrDev, IsDev implies ProdOrDev
2124        assert!(model.implications.iter().any(|i| i.antecedent == "IsProd" && i.consequent == "ProdOrDev"));
2125        assert!(model.implications.iter().any(|i| i.antecedent == "IsDev" && i.consequent == "ProdOrDev"));
2126    }
2127
2128    #[test]
2129    fn three_way_mutex_group() {
2130        let input = r#"
2131Parameters:
2132  Env:
2133    Type: String
2134    AllowedValues: [Prod, Dev, Stage]
2135Conditions:
2136  IsProd:
2137    Fn::Equals: [!Ref Env, Prod]
2138  IsDev:
2139    Fn::Equals: [!Ref Env, Dev]
2140  IsStage:
2141    Fn::Equals: [!Ref Env, Stage]
2142Resources:
2143  R:
2144    Type: T
2145"#;
2146        let model = build_condition_model(input);
2147        assert_eq!(model.mutex_groups.len(), 1);
2148        assert_eq!(model.mutex_groups[0].conditions.len(), 3);
2149        assert!(!model.conditions_compatible("IsProd", "IsDev"));
2150        assert!(!model.conditions_compatible("IsProd", "IsStage"));
2151        assert!(!model.conditions_compatible("IsDev", "IsStage"));
2152    }
2153
2154    #[test]
2155    fn resources_compatible_one_unconditional() {
2156        let input = r#"
2157Parameters:
2158  Env:
2159    Type: String
2160    AllowedValues: [Prod, Dev]
2161Conditions:
2162  IsProd:
2163    Fn::Equals: [!Ref Env, Prod]
2164Resources:
2165  R:
2166    Type: T
2167"#;
2168        let model = build_condition_model(input);
2169        assert!(model.resources_compatible(Some("IsProd"), None));
2170        assert!(model.resources_compatible(None, Some("IsProd")));
2171    }
2172
2173    #[test]
2174    fn condition_names_iterator() {
2175        let input = r#"
2176Conditions:
2177  A:
2178    Fn::Equals: ["x", "x"]
2179  B:
2180    Fn::Equals: ["y", "y"]
2181Resources:
2182  R:
2183    Type: T
2184"#;
2185        let model = build_condition_model(input);
2186        let mut names: Vec<&str> = model.names().collect();
2187        names.sort();
2188        assert_eq!(names, vec!["A", "B"]);
2189    }
2190
2191    #[test]
2192    fn get_returns_none_for_unknown_condition() {
2193        let input = r#"{"Resources":{"R":{"Type":"T"}}}"#;
2194        let model = build_condition_model(input);
2195        assert!(model.get("NonExistent").is_none(), "unknown condition should return None");
2196    }
2197
2198    #[test]
2199    fn not_condition_parsed() {
2200        let input = r#"
2201Parameters:
2202  Env:
2203    Type: String
2204    AllowedValues: [Prod, Dev]
2205Conditions:
2206  IsProd:
2207    Fn::Equals: [!Ref Env, Prod]
2208  NotProd:
2209    Fn::Not:
2210      - Condition: IsProd
2211Resources:
2212  R:
2213    Type: T
2214"#;
2215        let model = build_condition_model(input);
2216        assert!(!model.conditions_compatible("IsProd", "NotProd"));
2217        assert!(!model.condition_implies("IsProd", "NotProd"));
2218    }
2219
2220    #[test]
2221    fn pseudo_param_without_override_is_a_free_variable_in_sat_solver() {
2222        let input = r#"
2223Parameters:
2224  Env:
2225    Type: String
2226    AllowedValues: [Prod, Dev]
2227Conditions:
2228  IsUsEast1:
2229    Fn::Equals: [!Ref "AWS::Region", us-east-1]
2230  IsProd:
2231    Fn::Equals: [!Ref Env, Prod]
2232Resources:
2233  R:
2234    Type: T
2235"#;
2236        let model = build_condition_model(input);
2237        assert!(
2238            model.is_satisfiable(&[("IsUsEast1".into(), true)]),
2239            "IsUsEast1=true must be reachable: AWS::Region can equal 'us-east-1'"
2240        );
2241        assert!(
2242            model.is_satisfiable(&[("IsUsEast1".into(), false)]),
2243            "IsUsEast1=false must be reachable: AWS::Region can equal anything other than 'us-east-1' \
2244             (the auto-derived default region must not pin the pseudo-parameter to a constant - that \
2245             produced false-positive W1028 unreachable-branch diagnostics on partition/region branches)"
2246        );
2247        assert!(
2248            model.is_satisfiable(&[("IsProd".into(), true)]),
2249            "IsProd=true must be reachable: Env can equal 'Prod'"
2250        );
2251        assert!(
2252            model.is_satisfiable(&[("IsProd".into(), false)]),
2253            "IsProd=false must be reachable: Env can equal 'Dev'"
2254        );
2255    }
2256
2257    #[test]
2258    fn is_satisfiable_in_region_pins_aws_region() {
2259        let input = r#"
2260Conditions:
2261  IsUsEast1:
2262    Fn::Equals: [!Ref "AWS::Region", us-east-1]
2263Resources:
2264  R:
2265    Type: T
2266"#;
2267        let model = build_condition_model(input);
2268        // With AWS::Region left free, the region-equals condition can hold either
2269        // way; pinning the region resolves it. This is what the region-availability
2270        // check relies on to skip a resource whose condition cannot hold in the
2271        // target region - even when no explicit --region override is set.
2272        assert!(
2273            model.is_satisfiable_in_region(&[("IsUsEast1".into(), true)], "us-east-1"),
2274            "IsUsEast1=true must hold when the target region IS us-east-1"
2275        );
2276        assert!(
2277            !model.is_satisfiable_in_region(&[("IsUsEast1".into(), true)], "us-west-2"),
2278            "IsUsEast1=true must be unsatisfiable when the target region is us-west-2"
2279        );
2280        // A condition that does not reference AWS::Region is unaffected by the pin.
2281        assert!(
2282            model.is_satisfiable_in_region(&[("IsUsEast1".into(), false)], "us-west-2"),
2283            "IsUsEast1=false must hold at us-west-2"
2284        );
2285    }
2286
2287    #[test]
2288    fn pseudo_param_explicit_override_pins_value_in_sat_solver() {
2289        let ir = parser::parse(
2290            r#"
2291Conditions:
2292  IsAwsPartition:
2293    Fn::Equals: [!Ref "AWS::Partition", aws]
2294Resources:
2295  R:
2296    Type: T
2297"#
2298            .as_bytes(),
2299        )
2300        .unwrap();
2301        let (params, _) = extract_parameters(&ir);
2302        let (mappings, _) = extract_mappings(&ir);
2303        let pseudo = PseudoParameterOverrides { partition: Some("aws-cn".to_string()), ..Default::default() };
2304
2305        let model = ConditionModel::from_ir(&ir, &params, &pseudo, &mappings);
2306
2307        assert!(
2308            !model.is_satisfiable(&[("IsAwsPartition".into(), true)]),
2309            "with partition pinned to aws-cn, IsAwsPartition=true must be unsatisfiable"
2310        );
2311        assert!(
2312            model.is_satisfiable(&[("IsAwsPartition".into(), false)]),
2313            "with partition pinned to aws-cn, IsAwsPartition=false must hold"
2314        );
2315    }
2316
2317    #[test]
2318    fn pseudo_param_custom_region_changes_satisfiability() {
2319        let ir = parser::parse(
2320            r#"
2321Conditions:
2322  IsUsEast1:
2323    Fn::Equals: [!Ref "AWS::Region", us-east-1]
2324Resources:
2325  R:
2326    Type: T
2327"#
2328            .as_bytes(),
2329        )
2330        .unwrap();
2331        let (params, _) = extract_parameters(&ir);
2332        let (mappings, _) = extract_mappings(&ir);
2333        let pseudo = PseudoParameterOverrides { region: Some("eu-west-1".to_string()), ..Default::default() };
2334        let model = ConditionModel::from_ir(&ir, &params, &pseudo, &mappings);
2335        // With eu-west-1, IsUsEast1=true should be unsatisfiable
2336        assert!(!model.is_satisfiable(&[("IsUsEast1".into(), true)]));
2337        assert!(model.is_satisfiable(&[("IsUsEast1".into(), false)]));
2338    }
2339
2340    #[test]
2341    fn pseudo_param_partition_false_branch_is_reachable_without_override() {
2342        let input = r#"
2343Conditions:
2344  HasEcrPublic:
2345    Fn::Equals: [!Ref "AWS::Partition", aws]
2346Resources:
2347  R:
2348    Type: T
2349"#;
2350        let model = build_condition_model(input);
2351
2352        assert!(
2353            model.is_satisfiable(&[("HasEcrPublic".into(), true)]),
2354            "HasEcrPublic=true must be reachable: AWS::Partition can equal 'aws'"
2355        );
2356        assert!(
2357            model.is_satisfiable(&[("HasEcrPublic".into(), false)]),
2358            "HasEcrPublic=false must be reachable: AWS::Partition can equal 'aws-cn' or 'aws-us-gov'"
2359        );
2360    }
2361
2362    #[test]
2363    fn mapping_lookup_resolves_in_condition() {
2364        let input = r#"
2365Parameters:
2366  Env:
2367    Type: String
2368    AllowedValues: [Prod, Dev]
2369Mappings:
2370  EnvMap:
2371    Prod:
2372      Label: production
2373    Dev:
2374      Label: development
2375Conditions:
2376  IsProdLabel:
2377    Fn::Equals:
2378      - !FindInMap [EnvMap, !Ref Env, Label]
2379      - production
2380Resources:
2381  R:
2382    Type: T
2383"#;
2384        let model = build_condition_model(input);
2385        // When Env=Prod, FindInMap returns "production" → IsProdLabel=true
2386        // When Env=Dev, FindInMap returns "development" → IsProdLabel=false
2387        assert!(model.is_satisfiable(&[("IsProdLabel".into(), true)]));
2388        assert!(model.is_satisfiable(&[("IsProdLabel".into(), false)]));
2389    }
2390
2391    #[test]
2392    fn mapping_lookup_format_value_expr_readable() {
2393        let expr = ValueExpr::MappingLookup {
2394            map_name: "MyMap".to_string(),
2395            key1: Box::new(ValueExpr::ParamRef("Env".to_string())),
2396            key2: Box::new(ValueExpr::Literal("Label".to_string())),
2397        };
2398        let formatted = format_value_expr(&expr);
2399        assert!(formatted.contains("FindInMap"));
2400        assert!(formatted.contains("MyMap"));
2401    }
2402
2403    #[test]
2404    fn mapping_lookup_param_refs_collected() {
2405        let input = r#"
2406Parameters:
2407  Env:
2408    Type: String
2409    AllowedValues: [Prod, Dev]
2410Mappings:
2411  EnvMap:
2412    Prod:
2413      Label: production
2414    Dev:
2415      Label: development
2416Conditions:
2417  IsProdLabel:
2418    Fn::Equals:
2419      - !FindInMap [EnvMap, !Ref Env, Label]
2420      - production
2421Resources:
2422  R:
2423    Type: T
2424"#;
2425        let model = build_condition_model(input);
2426        let refs = model.referenced_params();
2427        assert!(refs.contains(&"Env".to_string()));
2428    }
2429
2430    #[test]
2431    fn extract_equals_test_unwraps_not() {
2432        let expr = ConditionExpr::Not(Box::new(ConditionExpr::Equals(
2433            ValueExpr::ParamRef("Env".into()),
2434            ValueExpr::Literal("Prod".into()),
2435        )));
2436        let result = extract_equals_test(&expr);
2437        let (param, lit, positive) = result.expect("extract_equals_test should return Some for Not(Equals)");
2438        assert_eq!(param, "Env");
2439        assert_eq!(lit, "Prod");
2440        assert!(!positive);
2441    }
2442
2443    #[test]
2444    fn nested_and_implication_extracts_deep_condition_refs() {
2445        let input = r#"
2446Parameters:
2447  A:
2448    Type: String
2449    AllowedValues: [yes, no]
2450  B:
2451    Type: String
2452    AllowedValues: [yes, no]
2453  C:
2454    Type: String
2455    AllowedValues: [yes, no]
2456Conditions:
2457  CondA:
2458    Fn::Equals: [!Ref A, yes]
2459  CondB:
2460    Fn::Equals: [!Ref B, yes]
2461  CondC:
2462    Fn::Equals: [!Ref C, yes]
2463  NestedAnd:
2464    Fn::And:
2465      - Fn::And:
2466          - Condition: CondA
2467          - Condition: CondB
2468      - Condition: CondC
2469Resources:
2470  R:
2471    Type: T
2472"#;
2473        let model = build_condition_model(input);
2474        // NestedAnd = And(And(CondA, CondB), CondC) should imply all three
2475        assert!(model.implications.iter().any(|i| i.antecedent == "NestedAnd" && i.consequent == "CondA"));
2476        assert!(model.implications.iter().any(|i| i.antecedent == "NestedAnd" && i.consequent == "CondB"));
2477        assert!(model.implications.iter().any(|i| i.antecedent == "NestedAnd" && i.consequent == "CondC"));
2478    }
2479
2480    #[test]
2481    fn nested_or_implication_extracts_deep_condition_refs() {
2482        let input = r#"
2483Parameters:
2484  A:
2485    Type: String
2486    AllowedValues: [yes, no]
2487  B:
2488    Type: String
2489    AllowedValues: [yes, no]
2490Conditions:
2491  CondA:
2492    Fn::Equals: [!Ref A, yes]
2493  CondB:
2494    Fn::Equals: [!Ref B, yes]
2495  NestedOr:
2496    Fn::Or:
2497      - Fn::Or:
2498          - Condition: CondA
2499          - Condition: CondB
2500Resources:
2501  R:
2502    Type: T
2503"#;
2504        let model = build_condition_model(input);
2505        // CondA implies NestedOr, CondB implies NestedOr (through nested Or)
2506        assert!(model.implications.iter().any(|i| i.antecedent == "CondA" && i.consequent == "NestedOr"));
2507        assert!(model.implications.iter().any(|i| i.antecedent == "CondB" && i.consequent == "NestedOr"));
2508    }
2509
2510    #[test]
2511    fn register_inline_adds_condition_and_rebuilds_mutex() {
2512        let input = r#"
2513Parameters:
2514  Env:
2515    Type: String
2516    AllowedValues: [Prod, Dev]
2517Conditions:
2518  IsProd:
2519    Fn::Equals: [!Ref Env, Prod]
2520Resources:
2521  R:
2522    Type: T
2523"#;
2524        let mut model = build_condition_model(input);
2525        assert_eq!(model.conditions.len(), 1);
2526        // Register an inline condition that tests the same param
2527        model.register_inline(
2528            "__inline_1".to_string(),
2529            ConditionExpr::Equals(ValueExpr::ParamRef("Env".to_string()), ValueExpr::Literal("Dev".to_string())),
2530        );
2531        assert_eq!(model.conditions.len(), 2);
2532        // Mutex groups should be rebuilt to include the new condition
2533        assert!(!model.mutex_groups.is_empty());
2534        assert!(!model.conditions_compatible("IsProd", "__inline_1"));
2535    }
2536
2537    #[test]
2538    fn ifexpr_literal_equals_eagerly_evaluated() {
2539        // When IfExpr has Equals("a", "a"), it should resolve to the true branch directly
2540        let input = r#"{
2541  "Conditions": {"C": {"Fn::Equals": ["x", "x"]}},
2542  "Resources": {
2543    "R": {
2544      "Type": "T",
2545      "Properties": {
2546        "V": {"Fn::If": [{"Fn::Equals": ["same", "same"]}, "yes", "no"]}
2547      }
2548    }
2549  }
2550}"#;
2551        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2552        // Literal Equals("same","same") is true → should resolve to "yes" directly
2553        match model.resolve("R", "Properties.V") {
2554            Some(crate::resolver::ResolvedValue::Concrete { value: v }) => {
2555                assert_eq!(v.as_str().unwrap(), "yes");
2556            }
2557            other => panic!("Expected Concrete(\"yes\") from eager eval, got {:?}", other),
2558        }
2559    }
2560
2561    #[test]
2562    fn ifexpr_literal_not_equals_eagerly_evaluated_to_false_branch() {
2563        let input = r#"{
2564  "Conditions": {"C": {"Fn::Equals": ["x", "x"]}},
2565  "Resources": {
2566    "R": {
2567      "Type": "T",
2568      "Properties": {
2569        "V": {"Fn::If": [{"Fn::Equals": ["a", "b"]}, "yes", "no"]}
2570      }
2571    }
2572  }
2573}"#;
2574        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2575        match model.resolve("R", "Properties.V") {
2576            Some(crate::resolver::ResolvedValue::Concrete { value: v }) => {
2577                assert_eq!(v.as_str().unwrap(), "no");
2578            }
2579            other => panic!("Expected Concrete(\"no\") from eager eval, got {:?}", other),
2580        }
2581    }
2582
2583    #[test]
2584    fn ifexpr_non_literal_registers_inline_condition() {
2585        // When IfExpr has a non-trivial condition, it should register as inline
2586        let input = r#"
2587Parameters:
2588  Env:
2589    Type: String
2590    AllowedValues: [Prod, Dev]
2591Resources:
2592  R:
2593    Type: T
2594    Properties:
2595      V:
2596        Fn::If:
2597          - Fn::Equals: [!Ref Env, Prod]
2598          - big
2599          - small
2600"#;
2601        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2602        // Should have registered an inline condition
2603        let inline_count = model.conditions.conditions.keys().filter(|k| k.starts_with("__inline_cond_")).count();
2604        assert!(inline_count > 0, "Expected at least one inline condition registered");
2605    }
2606
2607    /// Builds a template whose conditions form a chain `chain_len` long, topped
2608    /// by an unsatisfiable `Contra = And(Top, Not(Top))`. `Top` transitively
2609    /// depends on the whole chain, so deciding `Contra` forces the solver to
2610    /// reason over every condition in the chain.
2611    ///
2612    /// Each link is written in De Morgan form (`Not(Or(Not(a), Not(b)))` for
2613    /// `And`, `Not(And(Not(a), Not(b)))` for `Or`) so the implication extractor
2614    /// (which only reads top-level `And`/`Or` structure) derives nothing from
2615    /// the chain. That keeps implication pruning out of the search, which is
2616    /// the point: this test exercises the *raw iteration budget*, not the
2617    /// implication constraints (covered by their own tests).
2618    fn chain_with_contradiction(chain_len: usize) -> String {
2619        let mut s = String::from(
2620            "Parameters:\n  P0:\n    Type: String\n    AllowedValues: [yes, no]\n  \
2621             P1:\n    Type: String\n    AllowedValues: [yes, no]\n\
2622             Conditions:\n  C000:\n    Fn::Equals: [!Ref P0, yes]\n  \
2623             C001:\n    Fn::Equals: [!Ref P1, yes]\n",
2624        );
2625        for i in 2..chain_len {
2626            // De Morgan: even links are And(a, b), odd links are Or(a, b).
2627            let outer_inner = if i % 2 == 0 { "Fn::Or" } else { "Fn::And" };
2628            let _ = write!(
2629                s,
2630                "  C{:03}:\n    Fn::Not:\n      - {}:\n          - Fn::Not:\n              - Condition: C{:03}\n          - Fn::Not:\n              - Condition: C{:03}\n",
2631                i,
2632                outer_inner,
2633                i - 1,
2634                i - 2
2635            );
2636        }
2637        let top = format!("C{:03}", chain_len - 1);
2638        let _ = write!(
2639            s,
2640            "  Contra:\n    Fn::And:\n      - Condition: {top}\n      \
2641             - Fn::Not:\n          - Condition: {top}\n\
2642             Resources:\n  R:\n    Type: T\n",
2643        );
2644        s
2645    }
2646
2647    #[test]
2648    fn contradiction_over_a_long_condition_chain_is_decided_exactly_and_cheaply() {
2649        // `Contra = And(Top, Not(Top))` is unsatisfiable, and Top depends on the
2650        // whole chain, so deciding it requires reasoning over every condition in
2651        // the chain. The chain is built over two binary parameters no matter how
2652        // long it gets, so the answer is decided by four parameter assignments -
2653        // the work must therefore grow with the chain's length, not with the
2654        // number of truth assignments its conditions could take.
2655        //
2656        // This is the shape that made a real build hang: conditions layered over a
2657        // handful of shared inputs, where searching condition assignments costs
2658        // exponentially more than searching the inputs themselves.
2659        const SHORT_CHAIN: usize = 8;
2660        const LONG_CHAIN: usize = 32;
2661        const DOUBLED_CHAIN: usize = 64;
2662
2663        for chain_length in [SHORT_CHAIN, LONG_CHAIN, DOUBLED_CHAIN] {
2664            let model = build_condition_model(&chain_with_contradiction(chain_length));
2665            assert!(
2666                !model.is_satisfiable(&[("Contra".to_string(), true)]),
2667                "a contradiction over a {chain_length}-condition chain must be proven \
2668                 unsatisfiable, not conceded as satisfiable because the search was too expensive"
2669            );
2670        }
2671
2672        // Doubling the chain may at most double the work a few times over -
2673        // anything exponential in the condition count would exceed this by orders
2674        // of magnitude. Iteration counts are deterministic and machine-independent,
2675        // so this is a stable bound rather than a timing assertion.
2676        const MAX_GROWTH_FROM_DOUBLING: u64 = 4;
2677        let long_model = build_condition_model(&chain_with_contradiction(LONG_CHAIN));
2678        let _ = long_model.is_satisfiable(&[("Contra".to_string(), true)]);
2679        let doubled_model = build_condition_model(&chain_with_contradiction(DOUBLED_CHAIN));
2680        let _ = doubled_model.is_satisfiable(&[("Contra".to_string(), true)]);
2681        let long_chain_work = long_model.sat_iterations_used();
2682        let doubled_chain_work = doubled_model.sat_iterations_used();
2683        assert!(
2684            doubled_chain_work <= long_chain_work * MAX_GROWTH_FROM_DOUBLING,
2685            "doubling the chain length must not multiply the work by more than \
2686             {MAX_GROWTH_FROM_DOUBLING}x; {LONG_CHAIN} conditions cost {long_chain_work} steps and \
2687             {DOUBLED_CHAIN} cost {doubled_chain_work}"
2688        );
2689        assert!(
2690            doubled_chain_work < MAX_SAT_ITERATIONS,
2691            "deciding a chain of {DOUBLED_CHAIN} conditions over two binary parameters must stay \
2692             far inside the per-query budget; cost {doubled_chain_work} of {MAX_SAT_ITERATIONS}"
2693        );
2694    }
2695
2696    /// Builds a template shaped like the deployment templates that made CDK's
2697    /// default validation hang: `layered` conditions, each an `And`/`Or` over
2698    /// conditions that all test the same few pseudo-parameters, so every condition
2699    /// is reachable from every other through the inputs they share.
2700    fn conditions_layered_over_shared_pseudo_parameters(layered: usize) -> String {
2701        const PARTITIONS: [&str; 4] = ["aws", "aws-us-gov", "aws-cn", "aws-iso"];
2702        const REGIONS: [&str; 4] = ["us-east-1", "us-west-2", "us-gov-west-1", "cn-north-1"];
2703        let mut template = String::from("Parameters:\n  Stage:\n    Type: String\nConditions:\n");
2704        let mut base_names = Vec::new();
2705        for (index, partition) in PARTITIONS.iter().enumerate() {
2706            let _ = write!(template, "  IsPartition{index}:\n    Fn::Equals: [!Ref 'AWS::Partition', {partition}]\n");
2707            base_names.push(format!("IsPartition{index}"));
2708        }
2709        for (index, region) in REGIONS.iter().enumerate() {
2710            let _ = write!(template, "  IsRegion{index}:\n    Fn::Equals: [!Ref 'AWS::Region', {region}]\n");
2711            base_names.push(format!("IsRegion{index}"));
2712        }
2713        let _ = write!(template, "  IsProd:\n    Fn::Equals: [!Ref Stage, prod]\n");
2714        base_names.push("IsProd".to_string());
2715
2716        let mut names = base_names.clone();
2717        for index in 0..layered {
2718            let operator = if index % 2 == 0 { "Fn::And" } else { "Fn::Or" };
2719            let operands: Vec<&String> = (0..3).map(|offset| &names[(index * 7 + offset * 3) % names.len()]).collect();
2720            let _ = write!(template, "  Layered{index:03}:\n    {operator}:\n");
2721            for operand in operands {
2722                let _ = writeln!(template, "      - Condition: {operand}");
2723            }
2724            names.push(format!("Layered{index:03}"));
2725        }
2726        let _ = write!(
2727            template,
2728            "Resources:\n  R:\n    Type: AWS::SNS::Topic\n    Condition: {}\n",
2729            names[names.len() - 1]
2730        );
2731        template
2732    }
2733
2734    #[test]
2735    fn conditions_layered_over_shared_inputs_are_analyzed_in_full() {
2736        // The regression guard for the incident: a hundred conditions built over
2737        // nine base conditions that test three shared inputs. Analyzing this by
2738        // searching condition assignments cost so much that the cumulative budget
2739        // ran out partway through the pairwise pass, silently leaving most
2740        // condition pairs undecided. Searching parameter assignments instead must
2741        // decide every pair well inside the budget. The same shape at the
2742        // two-hundred-condition CloudFormation maximum is covered end to end by the
2743        // security fixtures.
2744        const LAYERED_CONDITIONS: usize = 91;
2745        let model = build_condition_model(&conditions_layered_over_shared_pseudo_parameters(LAYERED_CONDITIONS));
2746
2747        let mut names: Vec<&str> = model.names().collect();
2748        names.sort_unstable();
2749        assert_eq!(names.len(), LAYERED_CONDITIONS + 9, "the fixture must reach the scale under test");
2750
2751        let mut decided_pairs = 0u64;
2752        for (offset, first) in names.iter().enumerate() {
2753            for second in &names[offset + 1..] {
2754                let _ = model.conditions_compatible(first, second);
2755                decided_pairs += 1;
2756            }
2757        }
2758
2759        assert!(
2760            !model.satisfiability_budget_exhausted(),
2761            "the full pairwise pass over {} conditions must fit in the cumulative budget; it spent \
2762             {} of {MAX_TOTAL_SAT_ITERATIONS} deciding {decided_pairs} pairs",
2763            names.len(),
2764            model.sat_iterations_used()
2765        );
2766        assert!(
2767            model.budget_exhausted_queries().is_empty(),
2768            "no query over conditions layered on shared inputs may exceed its budget: {:?}",
2769            model.budget_exhausted_queries()
2770        );
2771        // Two conditions testing the same pseudo-parameter against different values
2772        // can never hold together. Proving that is what the pass exists for, and
2773        // what a budget-exhausted analysis silently stopped doing.
2774        assert!(
2775            !model.conditions_compatible("IsPartition0", "IsPartition1"),
2776            "conditions comparing the same pseudo-parameter with different values must be proven \
2777             incompatible"
2778        );
2779    }
2780
2781    #[test]
2782    fn cumulative_budget_exhaustion_is_reported_rather_than_silently_narrowing_analysis() {
2783        // Exhausting the cumulative budget makes every later question about the
2784        // condition set take the conservative answer, so the template's author has
2785        // to be told the analysis was curtailed - the failure that made a
2786        // multi-hour hang produce no output at all.
2787        let model = build_condition_model(&chain_with_contradiction(8));
2788        assert!(model.budget_exhausted_queries().is_empty(), "nothing is curtailed before any work is charged");
2789
2790        model.add_sat_iterations_for_test(MAX_TOTAL_SAT_ITERATIONS);
2791
2792        assert!(model.satisfiability_budget_exhausted(), "the budget must register as spent");
2793        assert!(
2794            !model.budget_exhausted_queries().is_empty(),
2795            "spending the cumulative budget must be reported so a curtailed analysis is never silent"
2796        );
2797    }
2798
2799    #[test]
2800    fn self_referential_conditions_are_undetermined_rather_than_looping() {
2801        // Two conditions that reference each other have no value to read. Deciding
2802        // a query over them must terminate - treating the cycle as undetermined,
2803        // the conservative direction - rather than recurse forever. The cycle
2804        // itself is reported by the reference-graph analysis.
2805        let model = build_condition_model(
2806            "Parameters:\n  Env:\n    Type: String\n    AllowedValues: [prod, dev]\n\
2807             Conditions:\n  Looping:\n    Fn::And:\n      - Condition: AlsoLooping\n      \
2808             - Fn::Equals: [!Ref Env, prod]\n  \
2809             AlsoLooping:\n    Fn::Or:\n      - Condition: Looping\n      \
2810             - Fn::Equals: [!Ref Env, dev]\n\
2811             Resources:\n  R:\n    Type: T\n",
2812        );
2813        assert!(
2814            model.is_satisfiable(&[("Looping".to_string(), true)]),
2815            "a condition on a reference cycle cannot be decided, so it must be assumed satisfiable"
2816        );
2817        assert!(model.is_satisfiable(&[("Looping".to_string(), false)]), "the same holds for the negated assumption");
2818        assert!(
2819            model.sat_iterations_used() < MAX_SAT_ITERATIONS,
2820            "a cycle must be cut immediately, not explored until the budget runs out; cost {}",
2821            model.sat_iterations_used()
2822        );
2823    }
2824
2825    #[test]
2826    fn condition_over_an_unresolvable_value_does_not_constrain_a_query() {
2827        // A condition comparing something the model cannot resolve has no value at
2828        // any parameter assignment. It must not falsify a query - that would
2829        // suppress diagnostics for resources it guards - and must not make the
2830        // conditions it is combined with undecidable either.
2831        let model = build_condition_model(
2832            "Parameters:\n  Subnets:\n    Type: CommaDelimitedList\n  \
2833             Env:\n    Type: String\n    AllowedValues: [prod, dev]\n\
2834             Conditions:\n  \
2835             FirstSubnetIsReserved:\n    Fn::Equals: [!Select [0, !Ref Subnets], reserved]\n  \
2836             IsProd:\n    Fn::Equals: [!Ref Env, prod]\n  \
2837             IsDev:\n    Fn::Equals: [!Ref Env, dev]\n  \
2838             ReservedInProd:\n    Fn::And:\n      - Condition: FirstSubnetIsReserved\n      \
2839             - Condition: IsProd\n\
2840             Resources:\n  R:\n    Type: T\n",
2841        );
2842        assert!(
2843            model.is_satisfiable(&[("FirstSubnetIsReserved".to_string(), true)]),
2844            "an unresolvable comparison must be assumed able to hold"
2845        );
2846        assert!(
2847            model.conditions_compatible("ReservedInProd", "IsProd"),
2848            "an unresolvable operand must not make a combined condition look impossible"
2849        );
2850        assert!(
2851            !model.conditions_compatible("ReservedInProd", "IsDev"),
2852            "the resolvable operand must still constrain the combination: the And requires prod, \
2853             which excludes dev"
2854        );
2855    }
2856
2857    #[test]
2858    fn conditions_sharing_a_parameter_through_derived_conditions_stay_exact() {
2859        // Two derived conditions that reach the same parameter through different
2860        // chains of references. Their compatibility follows only from the values
2861        // that parameter can take, so it must be decided exactly however deeply the
2862        // conditions are layered.
2863        let model = build_condition_model(
2864            "Parameters:\n  Env:\n    Type: String\n    AllowedValues: [prod, gamma, dev]\n  \
2865             Feature:\n    Type: String\n    AllowedValues: ['on', 'off']\n\
2866             Conditions:\n  \
2867             IsProd:\n    Fn::Equals: [!Ref Env, prod]\n  \
2868             IsDev:\n    Fn::Equals: [!Ref Env, dev]\n  \
2869             FeatureOn:\n    Fn::Equals: [!Ref Feature, 'on']\n  \
2870             ProdWithFeature:\n    Fn::And:\n      - Condition: IsProd\n      - Condition: FeatureOn\n  \
2871             DevWithFeature:\n    Fn::And:\n      - Condition: IsDev\n      - Condition: FeatureOn\n  \
2872             EitherWithFeature:\n    Fn::Or:\n      - Condition: ProdWithFeature\n      \
2873             - Condition: DevWithFeature\n\
2874             Resources:\n  R:\n    Type: T\n",
2875        );
2876        assert!(
2877            !model.conditions_compatible("ProdWithFeature", "DevWithFeature"),
2878            "conditions requiring different values of the same parameter cannot both hold, however \
2879             many references separate them from that parameter"
2880        );
2881        assert!(
2882            model.conditions_compatible("ProdWithFeature", "EitherWithFeature"),
2883            "a condition and a disjunction it satisfies must remain compatible"
2884        );
2885        assert!(
2886            model.condition_implies("ProdWithFeature", "FeatureOn"),
2887            "a conjunction must be proven to imply its operand"
2888        );
2889        assert!(
2890            !model.condition_implies("EitherWithFeature", "IsProd"),
2891            "a disjunction must not be proven to imply only one of its branches"
2892        );
2893    }
2894
2895    /// Builds a model with `param_count` binary parameters, one base condition
2896    /// per parameter, and a `Wide` condition that is the conjunction of every
2897    /// base - so a query over `Wide` has a dependency closure spanning all of
2898    /// the parameters.
2899    fn wide_parameter_closure(param_count: usize) -> String {
2900        let mut s = String::from("Parameters:\n");
2901        for i in 0..param_count {
2902            let _ = write!(s, "  P{i:02}:\n    Type: String\n    AllowedValues: [yes, no]\n");
2903        }
2904        s.push_str("Conditions:\n");
2905        for i in 0..param_count {
2906            let _ = write!(s, "  Base{i:02}:\n    Fn::Equals: [!Ref P{i:02}, yes]\n");
2907        }
2908        s.push_str("  Wide:\n    Fn::And:\n");
2909        for i in 0..param_count {
2910            let _ = writeln!(s, "      - Condition: Base{i:02}");
2911        }
2912        s.push_str("Resources:\n  R:\n    Type: T\n");
2913        s
2914    }
2915
2916    #[test]
2917    fn satisfiability_with_wide_parameter_closure_is_capped_conservatively() {
2918        // 24 binary parameters means 2^24 value combinations - far above
2919        // MAX_PARAM_COMBINATIONS. Enumerating them at every search leaf is the
2920        // denial-of-service shape the parameter cap defends against.
2921        const WIDE_PARAMS: usize = 24;
2922        let mut model = build_condition_model(&wide_parameter_closure(WIDE_PARAMS));
2923        let wide_expression = model.get("Wide").cloned().expect("the generated model must contain Wide");
2924        model.register_inline("__inline_cond_test".to_string(), wide_expression);
2925
2926        // The query must return the conservative "satisfiable" answer rather
2927        // than enumerate the cartesian product. Supply the assumptions in reverse
2928        // display order so the recorded advisory also proves its rendering is
2929        // deterministic and does not expose synthetic condition identifiers.
2930        assert!(
2931            model.is_satisfiable(&[("Base00".to_string(), true), ("__inline_cond_test".to_string(), true)]),
2932            "a condition whose closure references more parameters than the cap must be \
2933             assumed satisfiable, not enumerated"
2934        );
2935        assert_eq!(
2936            model.budget_exhausted_queries(),
2937            vec!["<inline condition>=true, Base00=true".to_string()],
2938            "budget advisories must canonicalize assumption order and describe synthetic conditions generically"
2939        );
2940
2941        // And it must do so cheaply: the cap short-circuits before the search,
2942        // so the work charged stays far below the combination count that a full
2943        // enumeration would have cost.
2944        assert!(
2945            model.sat_iterations_used() < MAX_PARAM_COMBINATIONS,
2946            "the parameter cap must short-circuit before enumerating the cartesian product; \
2947             charged {} iterations, expected well under {MAX_PARAM_COMBINATIONS}",
2948            model.sat_iterations_used()
2949        );
2950    }
2951
2952    #[test]
2953    fn satisfiability_with_narrow_parameter_closure_stays_exact() {
2954        // A contradiction over a single parameter is well under the cap, so the
2955        // solver must still prove it unsatisfiable rather than fall back to the
2956        // conservative answer - proving the cap does not over-trigger.
2957        let model = build_condition_model(
2958            "Parameters:\n  Env:\n    Type: String\n    AllowedValues: [prod, dev]\n\
2959             Conditions:\n  IsProd:\n    Fn::Equals: [!Ref Env, prod]\n  \
2960             IsDev:\n    Fn::Equals: [!Ref Env, dev]\n\
2961             Resources:\n  R:\n    Type: T\n",
2962        );
2963        assert!(
2964            !model.conditions_compatible("IsProd", "IsDev"),
2965            "mutually exclusive conditions over a small parameter space must remain provably \
2966             incompatible"
2967        );
2968    }
2969
2970    #[test]
2971    fn cumulative_satisfiability_budget_accumulates_across_queries_then_halts_search() {
2972        // A small contradiction chain whose query completes exactly and cheaply
2973        // while still charging a non-zero, deterministic amount to the model's
2974        // shared cumulative counter - enough to prove queries accumulate without
2975        // spending the full per-query budget on every call.
2976        const CHAIN: usize = 8;
2977        let model = build_condition_model(&chain_with_contradiction(CHAIN));
2978
2979        assert_eq!(model.sat_iterations_used(), 0, "a freshly built model has spent none of its cumulative budget");
2980        assert!(!model.satisfiability_budget_exhausted(), "a freshly built model's cumulative budget is not exhausted");
2981
2982        // (1) Real queries accumulate across queries - a per-query reset would
2983        // be a silent denial-of-service regression. Issuing the same saturating
2984        // query repeatedly must strictly increase the shared counter.
2985        let mut previous = 0u64;
2986        for _ in 0..3 {
2987            let _ = model.is_satisfiable(&[("Contra".to_string(), true)]);
2988            let used = model.sat_iterations_used();
2989            assert!(
2990                used > previous,
2991                "each query while under budget must add to the shared cumulative counter; a \
2992                 per-query reset would be a silent denial-of-service regression. was {previous}, \
2993                 now {used}"
2994            );
2995            previous = used;
2996        }
2997        assert!(
2998            !model.satisfiability_budget_exhausted(),
2999            "a handful of queries must not exhaust the (large) cumulative budget"
3000        );
3001
3002        // (2) The exhausted flag trips exactly at the cumulative threshold.
3003        // Fast-forward to one iteration short of the cap rather than burning
3004        // ~MAX_TOTAL_SAT_ITERATIONS of real search (pointless seconds of work);
3005        // the accumulation checked in (1) already proves real queries feed this
3006        // same counter.
3007        let to_threshold = MAX_TOTAL_SAT_ITERATIONS - model.sat_iterations_used() - 1;
3008        model.add_sat_iterations_for_test(to_threshold);
3009        assert!(
3010            !model.satisfiability_budget_exhausted(),
3011            "one iteration short of the cap must not be exhausted; counter is {}",
3012            model.sat_iterations_used()
3013        );
3014        model.add_sat_iterations_for_test(1);
3015        assert!(
3016            model.satisfiability_budget_exhausted(),
3017            "reaching MAX_TOTAL_SAT_ITERATIONS must trip the exhausted flag; counter is {}",
3018            model.sat_iterations_used()
3019        );
3020
3021        // (3) Once exhausted, further queries must short-circuit in O(1): they
3022        // return the conservative satisfiable answer and charge no further work.
3023        let before_short_circuit = model.sat_iterations_used();
3024        let conservative = model.is_satisfiable(&[("Contra".to_string(), true)]);
3025        assert!(
3026            conservative,
3027            "a query issued after the cumulative budget is exhausted must return the conservative \
3028             satisfiable answer"
3029        );
3030        assert_eq!(
3031            model.sat_iterations_used(),
3032            before_short_circuit,
3033            "an exhausted-budget query must short-circuit without performing or charging further \
3034             search work"
3035        );
3036    }
3037
3038    #[test]
3039    fn rules_section_implication_eliminates_impossible_scenario() {
3040        // Rules: when Env == prod, an assertion requires Env == dev - so a
3041        // condition equivalent to Env == prod can never hold. The implication
3042        // must actually constrain the SAT search, not merely be recorded.
3043        let yaml = b"
3044Parameters:
3045  Env:
3046    Type: String
3047Conditions:
3048  IsProd: !Equals [!Ref Env, prod]
3049Rules:
3050  RejectProd:
3051    RuleCondition: !Equals [!Ref Env, prod]
3052    Assertions:
3053      - Assert: !Equals [!Ref Env, dev]
3054Resources:
3055  B:
3056    Type: AWS::S3::Bucket
3057    Condition: IsProd
3058";
3059        let model = crate::SemanticModel::from_bytes(yaml).expect("model builds");
3060        assert!(
3061            !model.conditions.is_satisfiable(&[("IsProd".to_string(), true)]),
3062            "IsProd=true contradicts the Rules assertion and must be unsatisfiable"
3063        );
3064        assert!(model.conditions.is_satisfiable(&[("IsProd".to_string(), false)]), "IsProd=false remains satisfiable");
3065    }
3066
3067    #[test]
3068    fn rules_without_rule_condition_do_not_constrain_unrelated_queries() {
3069        // An unconditional assertion has no antecedent implication; unrelated
3070        // conditions stay satisfiable both ways.
3071        let yaml = b"
3072Parameters:
3073  Env:
3074    Type: String
3075  Other:
3076    Type: String
3077Conditions:
3078  UsesOther: !Equals [!Ref Other, x]
3079Rules:
3080  AlwaysDev:
3081    Assertions:
3082      - Assert: !Equals [!Ref Env, dev]
3083Resources:
3084  B:
3085    Type: AWS::S3::Bucket
3086    Condition: UsesOther
3087";
3088        let model = crate::SemanticModel::from_bytes(yaml).expect("model builds");
3089        assert!(model.conditions.is_satisfiable(&[("UsesOther".to_string(), true)]));
3090        assert!(model.conditions.is_satisfiable(&[("UsesOther".to_string(), false)]));
3091    }
3092
3093    #[test]
3094    fn mixed_nesting_produces_no_unsound_implications() {
3095        // `Complex = And(Or(A, B), Not(C))` entails none of A, B, C
3096        // individually, and none of them entails Complex. An unsound
3097        // implication here would be enforced by the search and wrongly prune
3098        // satisfiable scenarios (a real false-positive source for the
3099        // unreachable-branch rule).
3100        let yaml = b"
3101Parameters:
3102  Env:
3103    Type: String
3104    AllowedValues: [dev, prod]
3105  Flag:
3106    Type: String
3107    AllowedValues: ['true', 'false']
3108Conditions:
3109  A: !Equals [!Ref Env, prod]
3110  B: !Equals [!Ref Env, dev]
3111  C: !Equals [!Ref Flag, 'true']
3112  Complex: !And [!Or [!Condition A, !Condition B], !Not [!Condition C]]
3113  Inline: !And [!Condition B, !Condition C]
3114  NotA: !Not [!Condition A]
3115Resources:
3116  R:
3117    Type: AWS::S3::Bucket
3118    Condition: NotA
3119";
3120        let model = crate::SemanticModel::from_bytes(yaml).expect("model builds");
3121        assert!(
3122            !model.conditions.implications.iter().any(|i| i.antecedent == "Complex" || i.consequent == "Complex"),
3123            "no implication may involve the mixed-nesting condition: {:?}",
3124            model.conditions.implications
3125        );
3126        // Env=dev, Flag=false satisfies NotA=true with Inline=false.
3127        assert!(
3128            model.conditions.is_satisfiable(&[("NotA".to_string(), true), ("Inline".to_string(), false)]),
3129            "sound implications must not prune this satisfiable scenario"
3130        );
3131    }
3132}