Skip to main content

conjure_cp_core/ast/
partial_eval.rs

1use std::collections::HashSet;
2
3use indexmap::IndexMap;
4
5use crate::ast::Typeable;
6use crate::{
7    ast::{
8        AbstractLiteral, Atom, DomainPtr, Expression as Expr, GroundDomain, Literal as Lit,
9        Metadata, Moo, Range, ReturnType,
10    },
11    into_matrix_expr,
12    rule_engine::{ApplicationError::RuleNotApplicable, ApplicationResult, RuleEffect},
13};
14
15/// Constant comparison shape used when dominating bounds under `And`.
16///
17/// Only same-operator bounds on the same atomic LHS are merged. Integer strictness is left alone
18/// (`x > k` is not rewritten to `x >= k+1`) so later solver-family normalisers stay in control.
19#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
20enum ConstantBoundOp {
21    /// Keep the largest RHS: `x > a /\ x > b` ~> `x > max(a, b)`.
22    Gt,
23    /// Keep the largest RHS: `x >= a /\ x >= b` ~> `x >= max(a, b)`.
24    Geq,
25    /// Keep the smallest RHS: `x < a /\ x < b` ~> `x < min(a, b)`.
26    Lt,
27    /// Keep the smallest RHS: `x <= a /\ x <= b` ~> `x <= min(a, b)`.
28    Leq,
29}
30
31impl ConstantBoundOp {
32    /// Whether a larger RHS is the stronger bound for this operator.
33    fn prefers_larger_rhs(self) -> bool {
34        matches!(self, ConstantBoundOp::Gt | ConstantBoundOp::Geq)
35    }
36}
37
38/// Normalises integer ranges so equivalent domains compare structurally equal.
39fn normalise_int_domain(domain: &GroundDomain) -> GroundDomain {
40    match domain {
41        GroundDomain::Int(ranges) => GroundDomain::Int(Range::squeeze(
42            &ranges
43                .iter()
44                .map(|range| Range::new(range.low().copied(), range.high().copied()))
45                .collect::<Vec<_>>(),
46        )),
47        _ => domain.clone(),
48    }
49}
50
51/// Returns whether `expr` is safe after resolving any referenced expressions.
52fn is_semantically_safe(expr: &Expr) -> bool {
53    fn helper(expr: &Expr, resolving: &mut HashSet<crate::ast::serde::ObjId>) -> bool {
54        if matches!(
55            expr,
56            Expr::UnsafeDiv(_, _, _)
57                | Expr::UnsafeMod(_, _, _)
58                | Expr::UnsafePow(_, _, _)
59                | Expr::UnsafeIndex(_, _, _)
60                | Expr::Bubble(_, _, _)
61                | Expr::UnsafeSlice(_, _, _)
62        ) {
63            return false;
64        }
65
66        // Applying a partial function is undefined outside the positions it defines, so it has to
67        // keep its undefinedness until a bubble discharges it.
68        if let Expr::Image(_, subject, argument) = expr
69            && crate::ast::expressions::image_can_be_undefined(subject, argument)
70        {
71            return false;
72        }
73
74        if let Expr::Atomic(_, Atom::Reference(reference)) = expr {
75            let id = reference.id();
76            if !resolving.insert(id.clone()) {
77                return false;
78            }
79            let is_safe = reference
80                .with_resolved_expression(|resolved| helper(resolved, resolving))
81                .unwrap_or(true);
82            resolving.remove(&id);
83            return is_safe;
84        }
85
86        let mut is_safe = true;
87        expr.for_each_expr_child(&mut |child| {
88            if is_safe {
89                is_safe = helper(child, resolving);
90            }
91        });
92        is_safe
93    }
94
95    helper(expr, &mut HashSet::new())
96}
97
98/// Tries to decide `expr in domain` from resolved domains alone.
99fn simplify_in_domain(expr: &Expr, domain: &DomainPtr) -> Option<bool> {
100    if !is_semantically_safe(expr) {
101        return None;
102    }
103
104    let expr_domain = resolved_ground_domain_of_for_partial_eval(expr)?;
105    let domain = domain.resolve().ok()?;
106    let intersection = expr_domain.intersect(&domain).ok()?;
107
108    if normalise_int_domain(&intersection) == normalise_int_domain(expr_domain.as_ref()) {
109        return Some(true);
110    }
111
112    if let Ok(values_in_domain) = intersection.values_i32()
113        && values_in_domain.is_empty()
114    {
115        return Some(false);
116    }
117
118    None
119}
120
121/// Extracts an integer when `expr` is known to be a singleton integer value.
122fn singleton_int_value(expr: &Expr) -> Option<i32> {
123    if let Ok(value) = expr.try_into() {
124        return Some(value);
125    }
126
127    let domain = resolved_ground_domain_of_for_partial_eval(expr)?;
128    let GroundDomain::Int(ranges) = domain.as_ref() else {
129        return None;
130    };
131    let [range] = ranges.as_slice() else {
132        return None;
133    };
134    let (Some(low), Some(high)) = (range.low(), range.high()) else {
135        return None;
136    };
137
138    if low == high { Some(*low) } else { None }
139}
140
141/// Extracts a singleton integer without deriving the domain of a compound expression.
142///
143/// Local partial evaluation runs on every dirty ancestor. Calling `domain_of` for arithmetic
144/// expressions here can enumerate the Cartesian product of both operand domains, turning a cheap
145/// node-local check into the dominant translation cost. Literal and declaration-domain lookup are
146/// the constant-size cases needed to fold operations such as `x ** 2` for `x : int(2..2)`.
147fn cheap_singleton_int_value(expr: &Expr) -> Option<i32> {
148    match expr {
149        Expr::Atomic(_, Atom::Literal(Lit::Int(value))) => Some(*value),
150        Expr::Atomic(_, Atom::Reference(reference)) => {
151            let domain = reference.domain()?.resolve().ok()?;
152            let GroundDomain::Int(ranges) = domain.as_ref() else {
153                return None;
154            };
155            let [range] = ranges.as_slice() else {
156                return None;
157            };
158            let (Some(low), Some(high)) = (range.low(), range.high()) else {
159                return None;
160            };
161            (low == high).then_some(*low)
162        }
163        _ => None,
164    }
165}
166
167fn matrix_index_offset(index_domain: &DomainPtr, index: i32) -> Option<usize> {
168    let ranges = index_domain.as_int_ground()?;
169    let [range] = ranges.as_slice() else {
170        return None;
171    };
172    let from = *range.low()?;
173    usize::try_from(index.checked_sub(from)?).ok()
174}
175
176fn ground_matrix_index_offset(index_domain: &GroundDomain, index: i32) -> Option<usize> {
177    let GroundDomain::Int(ranges) = index_domain else {
178        return None;
179    };
180    let [range] = ranges.as_slice() else {
181        return None;
182    };
183    let from = *range.low()?;
184    usize::try_from(index.checked_sub(from)?).ok()
185}
186
187/// Selects one element from a matrix literal, including a referenced constant matrix.
188///
189/// This deliberately clones only the selected element. Resolving the complete matrix into an
190/// owned `Vec` for every index makes N selections from an N-element matrix quadratic.
191fn resolve_matrix_element(subject: &Expr, index: i32) -> Option<Expr> {
192    match subject {
193        Expr::TypeAnnotation(_, inner, _) | Expr::DomainAnnotation(_, inner, _) => {
194            resolve_matrix_element(inner, index)
195        }
196        Expr::AbstractLiteral(_, AbstractLiteral::Matrix(elems, index_domain)) => elems
197            .get(matrix_index_offset(index_domain, index)?)
198            .cloned(),
199        Expr::Atomic(
200            _,
201            Atom::Literal(Lit::AbstractLiteral(AbstractLiteral::Matrix(elems, index_domain))),
202        ) => elems
203            .get(ground_matrix_index_offset(index_domain, index)?)
204            .cloned()
205            .map(|literal| Expr::Atomic(Metadata::new(), Atom::Literal(literal))),
206        Expr::Atomic(_, Atom::Reference(reference)) => reference
207            .with_resolved_expression(|resolved| resolve_matrix_element(resolved, index))
208            .flatten()
209            .or_else(|| {
210                // Computed constant lettings are uncommon, but retain support for them. This
211                // fallback may materialise the value; direct matrix lettings above do not.
212                let Lit::AbstractLiteral(AbstractLiteral::Matrix(elems, index_domain)) =
213                    reference.resolve_constant()?
214                else {
215                    return None;
216                };
217                elems
218                    .get(ground_matrix_index_offset(&index_domain, index)?)
219                    .cloned()
220                    .map(|literal| Expr::Atomic(Metadata::new(), Atom::Literal(literal)))
221            }),
222        _ => None,
223    }
224}
225
226/// Resolves domains for partial evaluation while avoiding malformed indexing panics.
227fn resolved_ground_domain_of_for_partial_eval(expr: &Expr) -> Option<Moo<GroundDomain>> {
228    match expr {
229        Expr::SafeIndex(_, subject, _) => {
230            let subject_domain = resolved_ground_domain_of_for_partial_eval(subject)?;
231            let GroundDomain::Matrix(elem_domain, _) = subject_domain.as_ref() else {
232                return None;
233            };
234
235            Some(elem_domain.clone())
236        }
237        Expr::SafeSlice(_, subject, indices) => {
238            let subject_domain = resolved_ground_domain_of_for_partial_eval(subject)?;
239            let GroundDomain::Matrix(elem_domain, index_domains) = subject_domain.as_ref() else {
240                return None;
241            };
242            let sliced_dimension = indices.iter().position(Option::is_none);
243
244            match sliced_dimension {
245                Some(dimension) => Some(Moo::new(GroundDomain::Matrix(
246                    elem_domain.clone(),
247                    vec![index_domains[dimension].clone()],
248                ))),
249                None => Some(elem_domain.clone()),
250            }
251        }
252        Expr::UnsafeIndex(_, _, _) | Expr::UnsafeSlice(_, _, _) => None,
253        _ => expr.domain_of()?.resolve().ok(),
254    }
255}
256
257/// Whether `expr` is an undefined value whose domain is empty.
258///
259/// `min`/`max` of an empty collection is the only unsafe shape that carries an empty domain, so it
260/// is the only one for which [`simplify_comparison_with_literal`] derives a domain despite the
261/// expression not being semantically safe.
262fn is_empty_min_max(expr: &Expr) -> bool {
263    matches!(expr, Expr::Min(_, values) | Expr::Max(_, values) if is_empty_matrix_operand(values))
264}
265
266/// Whether `expr` is unsafe in a way that still leaves its domain worth deriving: it can be
267/// undefined, but every value it *can* take is a real one, so a literal outside that set decides
268/// the equality regardless.
269fn can_be_undefined_but_not_empty(expr: &Expr) -> bool {
270    matches!(expr, Expr::Image(_, _, _))
271}
272
273/// Tries to decide `expr = lit` and `expr != lit` from the resolved domain of `expr`.
274///
275/// Each direction is decided separately, because an expression that can be undefined does not
276/// settle both at once: where it is undefined, relational semantics make the containing boolean
277/// false, which agrees with a `false` equality but contradicts a `true` disequality.
278fn simplify_comparison_with_literal(
279    expr: &Expr,
280    lit: &Lit,
281) -> Option<(Option<bool>, Option<bool>)> {
282    // Deriving the domain of a compound expression enumerates its value set, and combining
283    // operands takes the Cartesian product of those sets -- packed set representations reach tens
284    // of thousands of values. An unsafe expression can still be decided below -- an empty
285    // `min`/`max`, or a literal its domain cannot hold -- but nothing else can, so a safe
286    // expression is worth the lookup unconditionally and an unsafe one only for those shapes.
287    let is_safe = is_semantically_safe(expr);
288    if !is_safe && !is_empty_min_max(expr) && !can_be_undefined_but_not_empty(expr) {
289        return None;
290    }
291
292    let expr_domain = resolved_ground_domain_of_for_partial_eval(expr)?;
293
294    // An empty domain represents an undefined value. Under relational semantics the closest
295    // containing Boolean expression is false, for both equality and disequality.
296    if matches!(expr_domain.as_ref(), GroundDomain::Empty(_)) {
297        return Some((Some(false), Some(false)));
298    }
299
300    if !expr_domain.contains(lit).ok()? {
301        // A representation rewrite can temporarily leave one side in its source shape and the
302        // other in its represented shape (for example, `record = tuple`). The source domain does
303        // not contain represented literals, but that does not make the original comparison false.
304        if let Expr::Atomic(_, Atom::Reference(reference)) = expr
305            && !reference.ptr().reprs().is_empty()
306        {
307            return None;
308        }
309        // No value the expression can take equals the literal, and where it is undefined the
310        // equality is false as well -- so the equality is false either way. The disequality is
311        // only true where the expression is defined, so it needs safety.
312        return Some((Some(false), is_safe.then_some(true)));
313    }
314
315    if !is_safe {
316        return None;
317    }
318
319    match (expr_domain.as_ref(), lit) {
320        (GroundDomain::Bool, Lit::Bool(_)) => None,
321        (GroundDomain::Int(ranges), Lit::Int(value)) => {
322            let [range] = ranges.as_slice() else {
323                return None;
324            };
325            let (Some(low), Some(high)) = (range.low(), range.high()) else {
326                return None;
327            };
328
329            if low == high && low == value {
330                Some((Some(true), Some(false)))
331            } else {
332                None
333            }
334        }
335        _ => None,
336    }
337}
338
339/// Whether deciding a comparison against `expr` is worth a domain lookup in this mode.
340///
341/// The local evaluator runs on every dirty ancestor after every rewrite, so it only derives a
342/// domain when that is a constant-size lookup: a declaration's own domain, or the empty domain of
343/// an empty `min`/`max`. Deep evaluation runs once per expansion and can afford the general case.
344fn comparison_domain_lookup_is_cheap(expr: &Expr, mode: PartialEvalMode) -> bool {
345    mode == PartialEvalMode::Deep
346        || matches!(expr, Expr::Atomic(_, Atom::Reference(_)))
347        || is_empty_min_max(expr)
348}
349
350/// Tries to decide reflexive equality and inequality when both sides are semantically safe.
351fn simplify_reflexive_comparison(x: &Expr, y: &Expr) -> Option<(bool, bool)> {
352    if x.identical_atom_to(y) && is_semantically_safe(x) && is_semantically_safe(y) {
353        return Some((true, false));
354    }
355
356    if is_semantically_safe(x) && is_semantically_safe(y) && x == y {
357        return Some((true, false));
358    }
359
360    None
361}
362
363fn simplify_reflexive_comparison_with_mode(
364    x: &Expr,
365    y: &Expr,
366    mode: PartialEvalMode,
367) -> Option<(bool, bool)> {
368    match mode {
369        PartialEvalMode::Deep => simplify_reflexive_comparison(x, y),
370        PartialEvalMode::Local => x.identical_atom_to(y).then_some((true, false)),
371    }
372}
373
374pub fn run_partial_evaluator(expr: &Expr) -> ApplicationResult {
375    run_partial_evaluator_with_mode(expr, PartialEvalMode::Deep)
376}
377
378/// Partially evaluates `expr` using only information already available at the focused node.
379///
380/// This is intended for the main rewriter, where recursive simplification is supplied by the
381/// scheduler. Use [`run_partial_evaluator`] when a caller explicitly wants semantic checks that
382/// can inspect referenced expressions.
383pub fn run_partial_evaluator_local(expr: &Expr) -> ApplicationResult {
384    run_partial_evaluator_with_mode(expr, PartialEvalMode::Local)
385}
386
387#[derive(Clone, Copy, Debug, PartialEq, Eq)]
388enum PartialEvalMode {
389    Deep,
390    Local,
391}
392
393/// Rewrites `x = true` / `true = x` to `x` when `x` is a non-literal boolean atom.
394///
395/// Nested uses such as `(x = true) <-> and([y = true, ...])` otherwise force Minion flattening to
396/// introduce aux variables for the left-hand `Eq`, because both sides of the outer equality are
397/// non-atomic. Lowering the tautological `= true` first keeps a boolean decision variable atomic
398/// so later equality/reify rules can target it directly.
399///
400/// Literal–literal equalities are left alone for constant folding. Non-boolean atoms are refused.
401pub fn try_lower_bool_atom_eq_true(expr: &Expr) -> Option<Expr> {
402    let Expr::Eq(_, left, right) = expr else {
403        return None;
404    };
405
406    let atom = match (left.as_ref(), right.as_ref()) {
407        (Expr::Atomic(_, Atom::Literal(Lit::Bool(true))), Expr::Atomic(_, atom))
408            if !matches!(atom, Atom::Literal(_)) =>
409        {
410            right.as_ref()
411        }
412        (Expr::Atomic(_, atom), Expr::Atomic(_, Atom::Literal(Lit::Bool(true))))
413            if !matches!(atom, Atom::Literal(_)) =>
414        {
415            left.as_ref()
416        }
417        _ => return None,
418    };
419
420    if atom.return_type() != ReturnType::Bool {
421        return None;
422    }
423
424    Some(atom.clone())
425}
426
427fn run_partial_evaluator_with_mode(expr: &Expr, mode: PartialEvalMode) -> ApplicationResult {
428    // NOTE: If nothing changes, we must return RuleNotApplicable, or the rewriter will try this
429    // rule infinitely!
430    // This is why we always check whether we found a constant or not.
431    match expr {
432        Expr::Difference(_, _, _) => Err(RuleNotApplicable),
433        Expr::Union(_, _, _) => Err(RuleNotApplicable),
434        Expr::In(_, _, _) => Err(RuleNotApplicable),
435        Expr::Intersect(_, _, _) => Err(RuleNotApplicable),
436        Expr::Supset(_, _, _) => Err(RuleNotApplicable),
437        Expr::SupsetEq(_, _, _) => Err(RuleNotApplicable),
438        Expr::Subset(_, _, _) => Err(RuleNotApplicable),
439        Expr::SubsetEq(_, _, _) => Err(RuleNotApplicable),
440        Expr::AbstractLiteral(_, _) => Err(RuleNotApplicable),
441        Expr::CatchUndef(_, _, _) => Err(RuleNotApplicable),
442        Expr::Comprehension(_, _) => Err(RuleNotApplicable),
443        Expr::DominanceRelation(_, _) => Err(RuleNotApplicable),
444        Expr::TypeAnnotation(_, _, _) => Err(RuleNotApplicable),
445        Expr::DomainAnnotation(_, _, _) => Err(RuleNotApplicable),
446        Expr::FromSolution(_, _) => Err(RuleNotApplicable),
447        Expr::Metavar(_, _) => Err(RuleNotApplicable),
448        Expr::UnsafeIndex(_, _, _) => Err(RuleNotApplicable),
449        Expr::UnsafeSlice(_, _, _) => Err(RuleNotApplicable),
450        Expr::Table(_, _, _) => Err(RuleNotApplicable),
451        Expr::NegativeTable(_, _, _) => Err(RuleNotApplicable),
452        Expr::AtLeast(_, _, _, _) => Err(RuleNotApplicable),
453        Expr::AtMost(_, _, _, _) => Err(RuleNotApplicable),
454        Expr::Gcc(_, _, _, _) | Expr::GccWeak(_, _, _, _) => Err(RuleNotApplicable),
455        Expr::RecordField(_, _, _) => Err(RuleNotApplicable),
456        Expr::AttributeAsConstraint(_, _, _, _) => Err(RuleNotApplicable),
457        Expr::SafeIndex(_, subject, indices) => {
458            // partially evaluate matrix literals indexed by a constant.
459            if indices.is_empty() {
460                return Err(RuleNotApplicable);
461            }
462
463            // the leading index must be fixed to a single value
464            let index = singleton_int_value(&indices[0]).ok_or(RuleNotApplicable)?;
465
466            let selected = resolve_matrix_element(subject, index).ok_or(RuleNotApplicable)?;
467            if indices.len() == 1 {
468                Ok(RuleEffect::pure(selected))
469            } else {
470                Ok(RuleEffect::pure(Expr::SafeIndex(
471                    Metadata::new(),
472                    Moo::new(selected),
473                    indices[1..].to_vec(),
474                )))
475            }
476        }
477        Expr::SafeSlice(_, _, _) => Err(RuleNotApplicable),
478        Expr::InDomain(_, x, domain) => {
479            if mode == PartialEvalMode::Deep
480                && let Some(result) = simplify_in_domain(x, domain)
481            {
482                Ok(RuleEffect::pure(Expr::Atomic(
483                    Metadata::new(),
484                    result.into(),
485                )))
486            } else if let Expr::Atomic(_, Atom::Reference(decl)) = x.as_ref() {
487                let decl_domain = decl
488                    .domain()
489                    .ok_or(RuleNotApplicable)?
490                    .resolve()
491                    .map_err(|_| RuleNotApplicable)?;
492                let domain = domain.resolve().map_err(|_| RuleNotApplicable)?;
493
494                let intersection = decl_domain
495                    .intersect(&domain)
496                    .map_err(|_| RuleNotApplicable)?;
497
498                // if the declaration's domain is a subset of domain, expr is always true.
499                if &intersection == decl_domain.as_ref() {
500                    Ok(RuleEffect::pure(Expr::Atomic(Metadata::new(), true.into())))
501                }
502                // if no elements of declaration's domain are in the domain (i.e. they have no
503                // intersection), expr is always false.
504                //
505                // Only check this when the intersection is a finite integer domain, as we
506                // currently don't have a way to check whether other domain kinds are empty or not.
507                //
508                // we should expand this to cover more domain types in the future.
509                else if let Ok(values_in_domain) = intersection.values_i32()
510                    && values_in_domain.is_empty()
511                {
512                    Ok(RuleEffect::pure(Expr::Atomic(
513                        Metadata::new(),
514                        false.into(),
515                    )))
516                } else {
517                    Err(RuleNotApplicable)
518                }
519            } else if let Expr::Atomic(_, Atom::Literal(lit)) = x.as_ref() {
520                if domain
521                    .resolve()
522                    .and_then(|gd| gd.contains(lit))
523                    .map_err(|_| RuleNotApplicable)?
524                {
525                    Ok(RuleEffect::pure(Expr::Atomic(Metadata::new(), true.into())))
526                } else {
527                    Ok(RuleEffect::pure(Expr::Atomic(
528                        Metadata::new(),
529                        false.into(),
530                    )))
531                }
532            } else {
533                Err(RuleNotApplicable)
534            }
535        }
536        Expr::Bubble(_, expr, cond) => {
537            // definition of bubble is "expr is valid as long as cond is true"
538            //
539            // check if cond is true and pop the bubble!
540            if let Expr::Atomic(_, Atom::Literal(Lit::Bool(true))) = cond.as_ref() {
541                Ok(RuleEffect::pure(Moo::unwrap_or_clone(expr.clone())))
542            } else {
543                Err(RuleNotApplicable)
544            }
545        }
546        Expr::Atomic(_, _) => Err(RuleNotApplicable),
547        Expr::ToInt(_, expression) => {
548            if expression.return_type() == ReturnType::Int {
549                Ok(RuleEffect::pure(Moo::unwrap_or_clone(expression.clone())))
550            } else {
551                Err(RuleNotApplicable)
552            }
553        }
554        Expr::Abs(m, e) => match e.as_ref() {
555            Expr::Neg(_, inner) => Ok(RuleEffect::pure(Expr::Abs(m.clone(), inner.clone()))),
556            _ => Err(RuleNotApplicable),
557        },
558        Expr::Sum(m, vec) => {
559            let vec = vec.unwrap_list_cow().ok_or(RuleNotApplicable)?;
560            let mut acc = 0;
561            let mut n_consts = 0;
562            for expr in vec.iter() {
563                if let Expr::Atomic(_, Atom::Literal(Lit::Int(x))) = expr {
564                    acc += *x;
565                    n_consts += 1;
566                }
567            }
568
569            if n_consts <= 1 {
570                return Err(RuleNotApplicable);
571            }
572
573            let mut new_vec: Vec<Expr> = vec
574                .iter()
575                .filter(|expr| !matches!(expr, Expr::Atomic(_, Atom::Literal(Lit::Int(_)))))
576                .cloned()
577                .collect();
578            if acc != 0 {
579                new_vec.push(Expr::Atomic(
580                    Default::default(),
581                    Atom::Literal(Lit::Int(acc)),
582                ));
583            }
584
585            Ok(RuleEffect::pure(Expr::Sum(
586                m.clone(),
587                Moo::new(into_matrix_expr![new_vec]),
588            )))
589        }
590
591        Expr::Product(m, vec) => {
592            let mut acc = 1;
593            let mut n_consts = 0;
594            let vec = vec.unwrap_list_cow().ok_or(RuleNotApplicable)?;
595            for expr in vec.iter() {
596                if let Expr::Atomic(_, Atom::Literal(Lit::Int(x))) = expr {
597                    acc *= *x;
598                    n_consts += 1;
599                }
600            }
601
602            if n_consts == 0 {
603                return Err(RuleNotApplicable);
604            }
605
606            if acc == 0 && mode == PartialEvalMode::Local {
607                return Err(RuleNotApplicable);
608            }
609
610            if acc != 0 && n_consts == 1 {
611                return Err(RuleNotApplicable);
612            }
613
614            let mut new_vec: Vec<Expr> = vec
615                .iter()
616                .filter(|expr| !matches!(expr, Expr::Atomic(_, Atom::Literal(Lit::Int(_)))))
617                .cloned()
618                .collect();
619
620            new_vec.push(Expr::Atomic(
621                Default::default(),
622                Atom::Literal(Lit::Int(acc)),
623            ));
624            let new_product = Expr::Product(m.clone(), Moo::new(into_matrix_expr![new_vec]));
625
626            if acc == 0 {
627                // If safe, 0 * exprs ~> 0. Otherwise do not reshuffle: appending the folded
628                // zero fights `reorder_product` (constant-first) and loops forever under the
629                // local evaluator. Constant folding/placement is left to `reorder_product`.
630                if mode == PartialEvalMode::Deep && is_semantically_safe(&new_product) {
631                    Ok(RuleEffect::pure(Expr::Atomic(
632                        Default::default(),
633                        Atom::Literal(Lit::Int(0)),
634                    )))
635                } else {
636                    Err(RuleNotApplicable)
637                }
638            } else {
639                // acc !=0, multiple constants found
640                Ok(RuleEffect::pure(new_product))
641            }
642        }
643
644        Expr::Min(m, e) => {
645            let Some(vec) = e.unwrap_list_cow() else {
646                return Err(RuleNotApplicable);
647            };
648            let mut acc: Option<i32> = None;
649            let mut n_consts = 0;
650            for expr in vec.iter() {
651                if let Expr::Atomic(_, Atom::Literal(Lit::Int(x))) = expr {
652                    n_consts += 1;
653                    acc = match acc {
654                        Some(i) => {
655                            if i > *x {
656                                Some(*x)
657                            } else {
658                                Some(i)
659                            }
660                        }
661                        None => Some(*x),
662                    };
663                }
664            }
665
666            if n_consts <= 1 {
667                return Err(RuleNotApplicable);
668            }
669
670            let mut new_vec: Vec<Expr> = vec
671                .iter()
672                .filter(|expr| !matches!(expr, Expr::Atomic(_, Atom::Literal(Lit::Int(_)))))
673                .cloned()
674                .collect();
675            if let Some(i) = acc {
676                new_vec.push(Expr::Atomic(Default::default(), Atom::Literal(Lit::Int(i))));
677            }
678
679            Ok(RuleEffect::pure(Expr::Min(
680                m.clone(),
681                Moo::new(into_matrix_expr![new_vec]),
682            )))
683        }
684
685        Expr::Max(m, e) => {
686            let Some(vec) = e.unwrap_list_cow() else {
687                return Err(RuleNotApplicable);
688            };
689
690            let mut acc: Option<i32> = None;
691            let mut n_consts = 0;
692            for expr in vec.iter() {
693                if let Expr::Atomic(_, Atom::Literal(Lit::Int(x))) = expr {
694                    n_consts += 1;
695                    acc = match acc {
696                        Some(i) => {
697                            if i < *x {
698                                Some(*x)
699                            } else {
700                                Some(i)
701                            }
702                        }
703                        None => Some(*x),
704                    };
705                }
706            }
707
708            if n_consts <= 1 {
709                return Err(RuleNotApplicable);
710            }
711
712            let mut new_vec: Vec<Expr> = vec
713                .iter()
714                .filter(|expr| !matches!(expr, Expr::Atomic(_, Atom::Literal(Lit::Int(_)))))
715                .cloned()
716                .collect();
717            if let Some(i) = acc {
718                new_vec.push(Expr::Atomic(Default::default(), Atom::Literal(Lit::Int(i))));
719            }
720
721            Ok(RuleEffect::pure(Expr::Max(
722                m.clone(),
723                Moo::new(into_matrix_expr![new_vec]),
724            )))
725        }
726        Expr::Not(_, e1) => {
727            let Expr::Imply(_, p, q) = e1.as_ref() else {
728                return Err(RuleNotApplicable);
729            };
730
731            if mode == PartialEvalMode::Deep && !is_semantically_safe(e1) {
732                return Err(RuleNotApplicable);
733            }
734
735            match (p.as_ref(), q.as_ref()) {
736                (_, Expr::Atomic(_, Atom::Literal(Lit::Bool(true)))) => {
737                    Ok(RuleEffect::pure(Expr::from(false)))
738                }
739                (_, Expr::Atomic(_, Atom::Literal(Lit::Bool(false)))) => {
740                    Ok(RuleEffect::pure(Moo::unwrap_or_clone(p.clone())))
741                }
742                (Expr::Atomic(_, Atom::Literal(Lit::Bool(true))), _) => {
743                    Ok(RuleEffect::pure(Expr::Not(Metadata::new(), q.clone())))
744                }
745                (Expr::Atomic(_, Atom::Literal(Lit::Bool(false))), _) => {
746                    Ok(RuleEffect::pure(Expr::from(false)))
747                }
748                _ => Err(RuleNotApplicable),
749            }
750        }
751        Expr::Or(m, e) => {
752            // Empty disjunction is the Or-identity, whatever index domain the matrix carries.
753            if is_empty_matrix_operand(e) {
754                return Ok(RuleEffect::pure(Expr::from(false)));
755            }
756
757            let Some(terms) = e.unwrap_list_cow() else {
758                return Err(RuleNotApplicable);
759            };
760
761            let mut has_changed = false;
762            let mut non_literal_terms = 0;
763
764            // Inspection pass: decide applicability from borrowed terms, so a disjunction that
765            // does not fold costs a walk rather than a copy.
766            for expr in terms.iter() {
767                if let Expr::Atomic(_, Atom::Literal(Lit::Bool(x))) = expr {
768                    has_changed = true;
769
770                    // true ~~> entire or is true
771                    // false ~~> remove false from the or
772                    if *x {
773                        return Ok(RuleEffect::pure(true.into()));
774                    }
775                } else {
776                    non_literal_terms += 1;
777                }
778            }
779
780            // The two supported implication tautologies, in expected O(n).
781            if check_pairwise_or_tautologies(&terms) {
782                return Ok(RuleEffect::pure(true.into()));
783            }
784
785            // 3. empty or ~~> false
786            if non_literal_terms == 0 {
787                return Ok(RuleEffect::pure(false.into()));
788            }
789
790            if !has_changed {
791                return Err(RuleNotApplicable);
792            }
793
794            let new_terms = terms
795                .iter()
796                .filter(|expr| !matches!(expr, Expr::Atomic(_, Atom::Literal(Lit::Bool(_)))))
797                .cloned()
798                .collect::<Vec<_>>();
799
800            Ok(RuleEffect::pure(Expr::Or(
801                m.clone(),
802                Moo::new(into_matrix_expr![new_terms]),
803            )))
804        }
805        Expr::And(_, e) => {
806            // The evaluator revisits a conjunction after each rewrite elsewhere in the model, so
807            // establish applicability from borrowed conjuncts and only build a replacement once
808            // the rule is known to apply. A wide `and` -- what unrolling `forAll i : D. ...`
809            // produces -- is then walked per visit rather than copied, keeping repeated visits
810            // linear rather than quadratic in the number of conjuncts.
811            // Empty conjunction is the And-identity, whatever index domain the matrix carries.
812            if is_empty_matrix_operand(e) {
813                return Ok(RuleEffect::pure(Expr::from(true)));
814            }
815
816            let Some(vec) = e.unwrap_list_cow() else {
817                return Err(RuleNotApplicable);
818            };
819
820            let mut has_changed: bool = false;
821            // `Atom` is only interior-mutable through `DeclarationPtr`, whose `Hash`/`Eq` use the
822            // immutable declaration id, so it is stable as a key.
823            #[allow(clippy::mutable_key_type)]
824            let mut distinct_bounds: HashSet<(&Atom, ConstantBoundOp)> = HashSet::new();
825            let mut constant_bound_terms: usize = 0;
826            for expr in vec.iter() {
827                if let Expr::Atomic(_, Atom::Literal(Lit::Bool(x))) = expr {
828                    if !x {
829                        return Ok(RuleEffect::pure(Expr::Atomic(
830                            Default::default(),
831                            Atom::Literal(Lit::Bool(false)),
832                        )));
833                    }
834                    has_changed = true;
835                } else if let Some((lhs, op, _)) = as_constant_bound_comparison_ref(expr) {
836                    constant_bound_terms += 1;
837                    distinct_bounds.insert((lhs, op));
838                }
839            }
840
841            // Only treat bound aggregation as a change when at least one conjunct was dominated.
842            if constant_bound_terms > distinct_bounds.len() {
843                has_changed = true;
844            }
845
846            if !has_changed {
847                return Err(RuleNotApplicable);
848            }
849            drop(distinct_bounds);
850
851            // The rule applies: now build the replacement.
852            let mut new_vec: Vec<Expr> = Vec::new();
853            // Strongest constant bound per (atomic LHS, comparison operator), first-seen order.
854            let mut constant_bounds: IndexMap<(Atom, ConstantBoundOp), i32> = IndexMap::new();
855            for expr in vec.iter() {
856                if matches!(expr, Expr::Atomic(_, Atom::Literal(Lit::Bool(_)))) {
857                    continue;
858                } else if let Some((lhs, op, rhs)) = as_constant_bound_comparison(expr) {
859                    merge_constant_bound(&mut constant_bounds, lhs, op, rhs);
860                } else {
861                    new_vec.push(expr.clone());
862                }
863            }
864
865            for ((lhs, op), rhs) in constant_bounds {
866                new_vec.push(make_constant_bound_comparison(lhs, op, rhs));
867            }
868
869            if new_vec.is_empty() {
870                Ok(RuleEffect::pure(Expr::from(true)))
871            } else {
872                Ok(RuleEffect::pure(Expr::And(
873                    Metadata::new(),
874                    Moo::new(into_matrix_expr![new_vec]),
875                )))
876            }
877        }
878
879        // similar to And, but booleans are returned wrapped in Root.
880        Expr::Root(_, es) => {
881            match es.as_slice() {
882                [] => Err(RuleNotApplicable),
883                // want to unwrap nested ands
884                [Expr::And(_, _)] => Ok(()),
885                // root([true]) / root([false]) are already evaluated
886                [_] => Err(RuleNotApplicable),
887                [_, _, ..] => Ok(()),
888            }?;
889
890            let mut new_vec: Vec<Expr> = Vec::new();
891            let mut has_changed: bool = false;
892            for expr in es {
893                match expr {
894                    Expr::Atomic(_, Atom::Literal(Lit::Bool(x))) => {
895                        has_changed = true;
896                        if !x {
897                            // false
898                            return Ok(RuleEffect::pure(Expr::Root(
899                                Metadata::new(),
900                                vec![Expr::Atomic(
901                                    Default::default(),
902                                    Atom::Literal(Lit::Bool(false)),
903                                )],
904                            )));
905                        }
906                        // remove trues
907                    }
908
909                    // flatten ands in root, applying the same true/false rules to conjuncts
910                    Expr::And(_, vecs) => match Moo::unwrap_or_clone(vecs.clone()).into_list() {
911                        Some(list) => {
912                            has_changed = true;
913                            for conjunct in list {
914                                match conjunct {
915                                    Expr::Atomic(_, Atom::Literal(Lit::Bool(false))) => {
916                                        return Ok(RuleEffect::pure(Expr::Root(
917                                            Metadata::new(),
918                                            vec![Expr::Atomic(
919                                                Default::default(),
920                                                Atom::Literal(Lit::Bool(false)),
921                                            )],
922                                        )));
923                                    }
924                                    Expr::Atomic(_, Atom::Literal(Lit::Bool(true))) => {}
925                                    other => new_vec.push(other),
926                                }
927                            }
928                        }
929                        None => new_vec.push(expr.clone()),
930                    },
931                    _ => new_vec.push(expr.clone()),
932                }
933            }
934
935            if !has_changed {
936                Err(RuleNotApplicable)
937            } else {
938                if new_vec.is_empty() {
939                    new_vec.push(true.into());
940                }
941                Ok(RuleEffect::pure(Expr::Root(Metadata::new(), new_vec)))
942            }
943        }
944        Expr::Imply(_m, x, y) => {
945            if let Expr::Atomic(_, Atom::Literal(Lit::Bool(x))) = x.as_ref() {
946                return if *x {
947                    // (true) -> y ~~> y
948                    Ok(RuleEffect::pure(Moo::unwrap_or_clone(y.clone())))
949                } else {
950                    // (false) -> y ~~> true
951                    Ok(RuleEffect::pure(Expr::Atomic(Metadata::new(), true.into())))
952                };
953            };
954
955            if let Expr::Atomic(_, Atom::Literal(Lit::Bool(y))) = y.as_ref() {
956                return if *y {
957                    // x -> (true) ~~> true
958                    Ok(RuleEffect::pure(Expr::from(true)))
959                } else {
960                    // x -> (false) ~~> !x
961                    Ok(RuleEffect::pure(Expr::Not(Metadata::new(), x.clone())))
962                };
963            };
964
965            // reflexivity: p -> p ~> true
966
967            // instead of checking syntactic equivalence of a possibly deep expression,
968            // let identical-CSE turn them into identical variables first. Then, check if they are
969            // identical variables.
970
971            if x.identical_atom_to(y.as_ref())
972                && (mode == PartialEvalMode::Local
973                    || (is_semantically_safe(x) && is_semantically_safe(y)))
974            {
975                return Ok(RuleEffect::pure(true.into()));
976            }
977
978            Err(RuleNotApplicable)
979        }
980        Expr::Iff(_m, x, y) => {
981            if let Expr::Atomic(_, Atom::Literal(Lit::Bool(x))) = x.as_ref() {
982                return if *x {
983                    // (true) <-> y ~~> y
984                    Ok(RuleEffect::pure(Moo::unwrap_or_clone(y.clone())))
985                } else {
986                    // (false) <-> y ~~> !y
987                    Ok(RuleEffect::pure(Expr::Not(Metadata::new(), y.clone())))
988                };
989            };
990            if let Expr::Atomic(_, Atom::Literal(Lit::Bool(y))) = y.as_ref() {
991                return if *y {
992                    // x <-> (true) ~~> x
993                    Ok(RuleEffect::pure(Moo::unwrap_or_clone(x.clone())))
994                } else {
995                    // x <-> (false) ~~> !x
996                    Ok(RuleEffect::pure(Expr::Not(Metadata::new(), x.clone())))
997                };
998            };
999
1000            // reflexivity: p <-> p ~> true
1001
1002            // instead of checking syntactic equivalence of a possibly deep expression,
1003            // let identical-CSE turn them into identical variables first. Then, check if they are
1004            // identical variables.
1005
1006            if x.identical_atom_to(y.as_ref())
1007                && (mode == PartialEvalMode::Local
1008                    || (is_semantically_safe(x) && is_semantically_safe(y)))
1009            {
1010                return Ok(RuleEffect::pure(true.into()));
1011            }
1012
1013            Err(RuleNotApplicable)
1014        }
1015        Expr::Eq(_, x, y) => {
1016            if let Some((eq_result, _)) = simplify_reflexive_comparison_with_mode(x, y, mode) {
1017                Ok(RuleEffect::pure(Expr::Atomic(
1018                    Metadata::new(),
1019                    Atom::Literal(Lit::Bool(eq_result)),
1020                )))
1021            } else if let Expr::Atomic(_, Atom::Literal(lit)) = x.as_ref()
1022                && comparison_domain_lookup_is_cheap(y, mode)
1023                && let Some((Some(eq_result), _)) = simplify_comparison_with_literal(y, lit)
1024            {
1025                Ok(RuleEffect::pure(Expr::Atomic(
1026                    Metadata::new(),
1027                    Atom::Literal(Lit::Bool(eq_result)),
1028                )))
1029            } else if let Expr::Atomic(_, Atom::Literal(lit)) = y.as_ref()
1030                && comparison_domain_lookup_is_cheap(x, mode)
1031                && let Some((Some(eq_result), _)) = simplify_comparison_with_literal(x, lit)
1032            {
1033                Ok(RuleEffect::pure(Expr::Atomic(
1034                    Metadata::new(),
1035                    Atom::Literal(Lit::Bool(eq_result)),
1036                )))
1037            } else if let Some(atom) = try_lower_bool_atom_eq_true(expr) {
1038                Ok(RuleEffect::pure(atom))
1039            } else {
1040                Err(RuleNotApplicable)
1041            }
1042        }
1043        Expr::Neq(_, x, y) => {
1044            if let Some((_, neq_result)) = simplify_reflexive_comparison_with_mode(x, y, mode) {
1045                Ok(RuleEffect::pure(Expr::Atomic(
1046                    Metadata::new(),
1047                    Atom::Literal(Lit::Bool(neq_result)),
1048                )))
1049            } else if let Expr::Atomic(_, Atom::Literal(lit)) = x.as_ref()
1050                && comparison_domain_lookup_is_cheap(y, mode)
1051                && let Some((_, Some(neq_result))) = simplify_comparison_with_literal(y, lit)
1052            {
1053                Ok(RuleEffect::pure(Expr::Atomic(
1054                    Metadata::new(),
1055                    Atom::Literal(Lit::Bool(neq_result)),
1056                )))
1057            } else if let Expr::Atomic(_, Atom::Literal(lit)) = y.as_ref()
1058                && comparison_domain_lookup_is_cheap(x, mode)
1059                && let Some((_, Some(neq_result))) = simplify_comparison_with_literal(x, lit)
1060            {
1061                Ok(RuleEffect::pure(Expr::Atomic(
1062                    Metadata::new(),
1063                    Atom::Literal(Lit::Bool(neq_result)),
1064                )))
1065            } else {
1066                Err(RuleNotApplicable)
1067            }
1068        }
1069        Expr::Geq(_, _, _) => Err(RuleNotApplicable),
1070        Expr::Leq(_, _, _) => Err(RuleNotApplicable),
1071        Expr::Gt(_, _, _) => Err(RuleNotApplicable),
1072        Expr::Lt(_, _, _) => Err(RuleNotApplicable),
1073        Expr::SafeDiv(_, _, _) => Err(RuleNotApplicable),
1074        Expr::UnsafeDiv(_, _, _) => Err(RuleNotApplicable),
1075        Expr::Flatten(_, _, _) => Err(RuleNotApplicable), // TODO: check if anything can be done here
1076        Expr::AllDiff(m, e) => {
1077            let Some((vec, _)) = Moo::unwrap_or_clone(e.clone()).unwrap_matrix_unchecked() else {
1078                return Err(RuleNotApplicable);
1079            };
1080
1081            let mut consts: HashSet<Lit> = HashSet::new();
1082
1083            // A fully constant allDiff can be decided immediately.
1084            for expr in vec {
1085                let Expr::Atomic(_, Atom::Literal(lit)) = expr else {
1086                    return Err(RuleNotApplicable);
1087                };
1088                if !consts.insert(lit) {
1089                    return Ok(RuleEffect::pure(Expr::Atomic(
1090                        m.clone(),
1091                        Atom::Literal(Lit::Bool(false)),
1092                    )));
1093                }
1094            }
1095
1096            Ok(RuleEffect::pure(Expr::Atomic(
1097                m.clone(),
1098                Atom::Literal(Lit::Bool(true)),
1099            )))
1100        }
1101        Expr::Neg(_, _) => Err(RuleNotApplicable),
1102        Expr::Factorial(_, _) => Err(RuleNotApplicable),
1103        Expr::AuxDeclaration(_, _, _) => Err(RuleNotApplicable),
1104        Expr::UnsafeMod(_, _, _) => Err(RuleNotApplicable),
1105        Expr::SafeMod(_, _, _) => Err(RuleNotApplicable),
1106        Expr::UnsafePow(_, _, _) => Err(RuleNotApplicable),
1107        Expr::SafePow(_, base, exponent) => {
1108            let base = cheap_singleton_int_value(base).ok_or(RuleNotApplicable)?;
1109            let exponent = cheap_singleton_int_value(exponent).ok_or(RuleNotApplicable)?;
1110            if exponent < 0 || (base == 0 && exponent == 0) {
1111                return Err(RuleNotApplicable);
1112            }
1113            let value = base.checked_pow(exponent as u32).ok_or(RuleNotApplicable)?;
1114            Ok(RuleEffect::pure(Expr::from(value)))
1115        }
1116        Expr::Minus(_, _, _) => Err(RuleNotApplicable),
1117        Expr::Card(_, _) => Err(RuleNotApplicable),
1118
1119        // As these are in a low level solver form, I'm assuming that these have already been
1120        // simplified and partially evaluated.
1121        Expr::FlatAllDiff(_, _) => Err(RuleNotApplicable),
1122        Expr::SmtDistinct(_, _) => Err(RuleNotApplicable),
1123        Expr::FlatAbsEq(_, _, _) => Err(RuleNotApplicable),
1124        Expr::FlatMinEq(_, _, _) => Err(RuleNotApplicable),
1125        Expr::FlatIneq(_, _, _, _) => Err(RuleNotApplicable),
1126        Expr::FlatMinusEq(_, _, _) => Err(RuleNotApplicable),
1127        Expr::FlatProductEq(_, _, _, _) => Err(RuleNotApplicable),
1128        Expr::FlatSumLeq(_, _, _) => Err(RuleNotApplicable),
1129        Expr::FlatSumGeq(_, _, _) => Err(RuleNotApplicable),
1130        Expr::FlatWatchedLiteral(_, _, _) => Err(RuleNotApplicable),
1131        Expr::FlatWeightedSumLeq(_, _, _, _) => Err(RuleNotApplicable),
1132        Expr::FlatWeightedSumGeq(_, _, _, _) => Err(RuleNotApplicable),
1133        Expr::MinionDivEqUndefZero(_, _, _, _) => Err(RuleNotApplicable),
1134        Expr::MinionModuloEqUndefZero(_, _, _, _) => Err(RuleNotApplicable),
1135        Expr::MinionPow(_, _, _, _) => Err(RuleNotApplicable),
1136        Expr::MinionReify(_, _, _) => Err(RuleNotApplicable),
1137        Expr::MinionReifyImply(_, _, _) => Err(RuleNotApplicable),
1138        Expr::MinionWInIntervalSet(_, _, _) => Err(RuleNotApplicable),
1139        Expr::MinionWInSet(_, _, _) => Err(RuleNotApplicable),
1140        Expr::MinionElementOne(_, _, _, _) => Err(RuleNotApplicable),
1141        Expr::SATInt(_, _, _, _) => Err(RuleNotApplicable),
1142        Expr::PairwiseSum(_, _, _) => Err(RuleNotApplicable),
1143        Expr::PairwiseProduct(_, _, _) => Err(RuleNotApplicable),
1144        Expr::Active(m, variant, alternative) => {
1145            let active_alternative = match variant.as_ref() {
1146                Expr::AbstractLiteral(_, AbstractLiteral::Variant(field)) => &field.name,
1147                Expr::Atomic(
1148                    _,
1149                    Atom::Literal(Lit::AbstractLiteral(AbstractLiteral::Variant(field))),
1150                ) => &field.name,
1151                _ => return Err(RuleNotApplicable),
1152            };
1153
1154            Ok(RuleEffect::pure(Expr::Atomic(
1155                m.clone(),
1156                Lit::Bool(active_alternative == alternative).into(),
1157            )))
1158        }
1159        // Semantic lowering for these lives in registered rules (function/set/mset/relation
1160        // horizontal rules), not here; the partial evaluator only offers extra constant-folding,
1161        // which `eval.rs`'s matching arms cover for the fully-constant case. Previously these
1162        // were `todo!()`, which panicked unconditionally on every rewrite of the operator (not
1163        // just constant ones), since `normalise_evaluator_local` calls this on every expression
1164        // node — the same landmine already found and fixed for Subsequence/Substring.
1165        Expr::Defined(_, _) => Err(RuleNotApplicable),
1166        Expr::Range(_, _) => Err(RuleNotApplicable),
1167        Expr::Image(_, _, _) => Err(RuleNotApplicable),
1168        Expr::ImageSet(_, _, _) => Err(RuleNotApplicable),
1169        Expr::PreImage(_, _, _) => Err(RuleNotApplicable),
1170        Expr::Inverse(_, _, _) => Err(RuleNotApplicable),
1171        Expr::PermInverse(_, _) => Err(RuleNotApplicable),
1172        Expr::Compose(_, _, _) => Err(RuleNotApplicable),
1173        Expr::Restrict(_, _, _) => Err(RuleNotApplicable),
1174        Expr::ToSet(_, _) => Err(RuleNotApplicable),
1175        Expr::ToMSet(_, _) => Err(RuleNotApplicable),
1176        Expr::ToRelation(_, _) => Err(RuleNotApplicable),
1177        Expr::RelationProj(_, _, _) => todo!(),
1178        Expr::Apart(_, _, _) => Err(RuleNotApplicable),
1179        Expr::Together(_, _, _) => Err(RuleNotApplicable),
1180        Expr::Participants(_, _) => Err(RuleNotApplicable),
1181        Expr::Party(_, _, _) => Err(RuleNotApplicable),
1182        Expr::Parts(_, _) => Err(RuleNotApplicable),
1183        Expr::Subsequence(_, _, _) => Err(RuleNotApplicable),
1184        Expr::Substring(_, _, _) => Err(RuleNotApplicable),
1185        Expr::LexLt(_, _, _) => Err(RuleNotApplicable),
1186        Expr::LexLeq(_, _, _) => Err(RuleNotApplicable),
1187        Expr::LexGt(_, _, _) => Err(RuleNotApplicable),
1188        Expr::LexGeq(_, _, _) => Err(RuleNotApplicable),
1189        Expr::FlatLexLt(_, _, _) => Err(RuleNotApplicable),
1190        Expr::FlatLexLeq(_, _, _) => Err(RuleNotApplicable),
1191        Expr::AllDifferentExcept(_, _, _) | Expr::ElementId(_, _, _) => Err(RuleNotApplicable),
1192    }
1193}
1194
1195/// Whether an `and`/`or` operand is a matrix literal holding no elements.
1196///
1197/// This deliberately ignores the index domain: `unwrap_list*` only recognise the normalised
1198/// `int(1..)`, and an empty `[]` parses to `[;int(1..0)]`. Matching the elements directly folds
1199/// `and([])` / `or([])` before `matrix_to_list` normalises the domain, and so before any
1200/// solver-family rule can fire on an expression that is already known to be constant.
1201fn is_empty_matrix_operand(operand: &Expr) -> bool {
1202    match operand {
1203        Expr::TypeAnnotation(_, inner, _) | Expr::DomainAnnotation(_, inner, _) => {
1204            is_empty_matrix_operand(inner)
1205        }
1206        Expr::AbstractLiteral(_, AbstractLiteral::Matrix(elems, _)) => elems.is_empty(),
1207        Expr::Atomic(_, Atom::Literal(Lit::AbstractLiteral(AbstractLiteral::Matrix(elems, _)))) => {
1208            elems.is_empty()
1209        }
1210        _ => false,
1211    }
1212}
1213
1214/// Extracts `lhs ▷ k` where `lhs` is atomic and `k` is an integer literal.
1215fn as_constant_bound_comparison(expr: &Expr) -> Option<(Atom, ConstantBoundOp, i32)> {
1216    as_constant_bound_comparison_ref(expr).map(|(lhs, op, rhs)| (lhs.clone(), op, rhs))
1217}
1218
1219/// Borrowing variant of [`as_constant_bound_comparison`], for the inspection pass.
1220fn as_constant_bound_comparison_ref(expr: &Expr) -> Option<(&Atom, ConstantBoundOp, i32)> {
1221    let (lhs, rhs, op) = match expr {
1222        Expr::Gt(_, lhs, rhs) => (lhs, rhs, ConstantBoundOp::Gt),
1223        Expr::Geq(_, lhs, rhs) => (lhs, rhs, ConstantBoundOp::Geq),
1224        Expr::Lt(_, lhs, rhs) => (lhs, rhs, ConstantBoundOp::Lt),
1225        Expr::Leq(_, lhs, rhs) => (lhs, rhs, ConstantBoundOp::Leq),
1226        _ => return None,
1227    };
1228
1229    let Expr::Atomic(_, Atom::Literal(Lit::Int(rhs_value))) = rhs.as_ref() else {
1230        return None;
1231    };
1232    let Expr::Atomic(_, lhs_atom) = lhs.as_ref() else {
1233        return None;
1234    };
1235    Some((lhs_atom, op, *rhs_value))
1236}
1237
1238/// Rebuilds a constant bound comparison from its dominated components.
1239fn make_constant_bound_comparison(lhs: Atom, op: ConstantBoundOp, rhs: i32) -> Expr {
1240    let lhs = Expr::Atomic(Metadata::new(), lhs);
1241    let rhs = Expr::Atomic(Metadata::new(), Atom::Literal(Lit::Int(rhs)));
1242    match op {
1243        ConstantBoundOp::Gt => Expr::Gt(Metadata::new(), Moo::new(lhs), Moo::new(rhs)),
1244        ConstantBoundOp::Geq => Expr::Geq(Metadata::new(), Moo::new(lhs), Moo::new(rhs)),
1245        ConstantBoundOp::Lt => Expr::Lt(Metadata::new(), Moo::new(lhs), Moo::new(rhs)),
1246        ConstantBoundOp::Leq => Expr::Leq(Metadata::new(), Moo::new(lhs), Moo::new(rhs)),
1247    }
1248}
1249
1250/// Keeps the strongest RHS for `(lhs, op)` under conjunction, preserving first-seen order.
1251///
1252/// Bounds are keyed rather than searched, so merging `n` bounds over distinct atoms -- the shape
1253/// unrolling `forAll i : D. x[i] >= k` produces -- is linear in `n` rather than quadratic.
1254/// `IndexMap` gives the lookup while keeping first-seen order.
1255fn merge_constant_bound(
1256    bounds: &mut IndexMap<(Atom, ConstantBoundOp), i32>,
1257    lhs: Atom,
1258    op: ConstantBoundOp,
1259    rhs: i32,
1260) {
1261    match bounds.entry((lhs, op)) {
1262        indexmap::map::Entry::Occupied(mut entry) => {
1263            let existing = entry.get_mut();
1264            if op.prefers_larger_rhs() {
1265                if rhs > *existing {
1266                    *existing = rhs;
1267                }
1268            } else if rhs < *existing {
1269                *existing = rhs;
1270            }
1271        }
1272        indexmap::map::Entry::Vacant(entry) => {
1273            entry.insert(rhs);
1274        }
1275    }
1276}
1277
1278/// Checks for tautologies involving pairs of terms inside an or, returning true if one is found.
1279///
1280/// This applies the following rules:
1281///
1282/// ```text
1283/// (p->q) \/ (q->p) ~> true    [totality of implication]
1284/// (p->q) \/ (p-> !q) ~> true  [conditional excluded middle]
1285/// ```
1286///
1287fn check_pairwise_or_tautologies(or_terms: &[Expr]) -> bool {
1288    // `identical_atom_to` can only succeed when both sides are atomic, so index the atomic pairs
1289    // and look each candidate up. This is expected O(n), not the O(n^2) of comparing every pair.
1290    #[allow(clippy::mutable_key_type)]
1291    let mut p_implies_q: HashSet<(&Atom, &Atom)> = HashSet::new();
1292    #[allow(clippy::mutable_key_type)]
1293    let mut p_implies_not_q: HashSet<(&Atom, &Atom)> = HashSet::new();
1294
1295    for term in or_terms {
1296        if let Expr::Imply(_, p, q) = term {
1297            if let Expr::Not(_, q_1) = q.as_ref() {
1298                if let (Expr::Atomic(_, p), Expr::Atomic(_, q)) = (p.as_ref(), q_1.as_ref()) {
1299                    p_implies_not_q.insert((p, q));
1300                }
1301            } else if let (Expr::Atomic(_, p), Expr::Atomic(_, q)) = (p.as_ref(), q.as_ref()) {
1302                p_implies_q.insert((p, q));
1303            }
1304        }
1305    }
1306
1307    // `(p->q) \/ (q->p) ~> true    [totality of implication]`
1308    for &(p, q) in &p_implies_q {
1309        if p_implies_q.contains(&(q, p)) {
1310            return true;
1311        }
1312    }
1313
1314    // `(p->q) \/ (p-> !q) ~> true`    [conditional excluded middle]
1315    p_implies_not_q
1316        .iter()
1317        .any(|pair| p_implies_q.contains(pair))
1318}
1319
1320#[cfg(test)]
1321mod tests {
1322    use super::*;
1323    use crate::ast::{DeclarationPtr, Domain, Name};
1324
1325    fn int_lit(value: i32) -> Expr {
1326        Expr::Atomic(Metadata::new(), Atom::Literal(Lit::Int(value)))
1327    }
1328
1329    fn bool_lit(value: bool) -> Expr {
1330        Expr::Atomic(Metadata::new(), Atom::Literal(Lit::Bool(value)))
1331    }
1332
1333    fn atom_ref(name: &str) -> Expr {
1334        Expr::Atomic(
1335            Metadata::new(),
1336            Atom::Reference(crate::ast::Reference::new(DeclarationPtr::new_find(
1337                Name::user(name),
1338                Domain::int(vec![Range::Bounded(1, 20)]),
1339            ))),
1340        )
1341    }
1342
1343    fn singleton_ref(name: &str, value: i32) -> Expr {
1344        Expr::Atomic(
1345            Metadata::new(),
1346            Atom::Reference(crate::ast::Reference::new(DeclarationPtr::new_find(
1347                Name::user(name),
1348                Domain::int(vec![Range::Bounded(value, value)]),
1349            ))),
1350        )
1351    }
1352
1353    fn safe_pow(base: Expr, exponent: Expr) -> Expr {
1354        Expr::SafePow(Metadata::new(), Moo::new(base), Moo::new(exponent))
1355    }
1356
1357    /// A power folds when both operands are singletons, taking the base from a declaration domain.
1358    #[test]
1359    fn safe_pow_folds_singleton_operands() {
1360        let reduced = run_partial_evaluator_local(&safe_pow(singleton_ref("x", 2), int_lit(3)))
1361            .expect("evaluates")
1362            .new_expression;
1363        assert_eq!(reduced, int_lit(8));
1364    }
1365
1366    /// A non-singleton operand leaves the power alone: its value is not yet known.
1367    #[test]
1368    fn safe_pow_keeps_non_singleton_operands() {
1369        assert!(run_partial_evaluator_local(&safe_pow(atom_ref("x"), int_lit(3))).is_err());
1370    }
1371
1372    /// `0 ** 0` is undefined, and a negative exponent has no integer result.
1373    #[test]
1374    fn safe_pow_leaves_undefined_powers_alone() {
1375        assert!(run_partial_evaluator_local(&safe_pow(int_lit(0), int_lit(0))).is_err());
1376        assert!(run_partial_evaluator_local(&safe_pow(int_lit(2), int_lit(-1))).is_err());
1377    }
1378
1379    /// Overflow is not folded: the model keeps the power rather than wrapping.
1380    #[test]
1381    fn safe_pow_leaves_overflowing_powers_alone() {
1382        assert!(run_partial_evaluator_local(&safe_pow(int_lit(i32::MAX), int_lit(2))).is_err());
1383    }
1384
1385    fn and(exprs: Vec<Expr>) -> Expr {
1386        Expr::And(Metadata::new(), Moo::new(into_matrix_expr![exprs]))
1387    }
1388
1389    fn or(exprs: Vec<Expr>) -> Expr {
1390        Expr::Or(Metadata::new(), Moo::new(into_matrix_expr![exprs]))
1391    }
1392
1393    /// An empty conjunction is the identity of `and`.
1394    ///
1395    /// The partial evaluator is the only thing that implements this: its hook runs after every
1396    /// rewrite, so it reaches an empty `and`/`or` before any rule can.
1397    #[test]
1398    fn empty_and_is_true() {
1399        let reduced = run_partial_evaluator_local(&and(vec![]))
1400            .expect("evaluates")
1401            .new_expression;
1402        assert_eq!(reduced, bool_lit(true));
1403    }
1404
1405    /// An empty disjunction is the identity of `or`. See [`empty_and_is_true`].
1406    #[test]
1407    fn empty_or_is_false() {
1408        let reduced = run_partial_evaluator_local(&or(vec![]))
1409            .expect("evaluates")
1410            .new_expression;
1411        assert_eq!(reduced, bool_lit(false));
1412    }
1413
1414    /// An empty matrix written as `[]` parses with the *empty* index domain `int(1..0)`, not the
1415    /// normalised `int(1..)` that `unwrap_list` looks for. The evaluator must fold it regardless,
1416    /// so that no solver-family rule sees an expression that is already known to be constant.
1417    fn empty_matrix_with_empty_index_domain() -> Expr {
1418        Expr::AbstractLiteral(
1419            Metadata::new(),
1420            AbstractLiteral::Matrix(
1421                vec![],
1422                DomainPtr::from(Domain::int(vec![Range::Bounded(1, 0)])),
1423            ),
1424        )
1425    }
1426
1427    #[test]
1428    fn empty_and_with_empty_index_domain_is_true() {
1429        let expr = Expr::And(
1430            Metadata::new(),
1431            Moo::new(empty_matrix_with_empty_index_domain()),
1432        );
1433        let reduced = run_partial_evaluator_local(&expr)
1434            .expect("evaluates")
1435            .new_expression;
1436        assert_eq!(reduced, bool_lit(true));
1437    }
1438
1439    #[test]
1440    fn empty_or_with_empty_index_domain_is_false() {
1441        let expr = Expr::Or(
1442            Metadata::new(),
1443            Moo::new(empty_matrix_with_empty_index_domain()),
1444        );
1445        let reduced = run_partial_evaluator_local(&expr)
1446            .expect("evaluates")
1447            .new_expression;
1448        assert_eq!(reduced, bool_lit(false));
1449    }
1450
1451    #[test]
1452    fn non_empty_and_is_not_collapsed_to_a_boolean() {
1453        let x = atom_ref("x");
1454        let expr = and(vec![Expr::Gt(
1455            Metadata::new(),
1456            Moo::new(x),
1457            Moo::new(int_lit(3)),
1458        )]);
1459        // Either it does not apply, or it rewrites to something that is not a bare boolean.
1460        if let Ok(reduced) = run_partial_evaluator_local(&expr) {
1461            assert_ne!(reduced.new_expression, bool_lit(true));
1462            assert_ne!(reduced.new_expression, bool_lit(false));
1463        }
1464    }
1465
1466    #[test]
1467    fn and_dominates_constant_lower_bounds_on_same_atom() {
1468        let x = atom_ref("x");
1469        let expr = and(vec![
1470            Expr::Gt(Metadata::new(), Moo::new(x.clone()), Moo::new(int_lit(3))),
1471            bool_lit(true),
1472            Expr::Gt(Metadata::new(), Moo::new(x.clone()), Moo::new(int_lit(9))),
1473            Expr::Gt(Metadata::new(), Moo::new(x), Moo::new(int_lit(1))),
1474        ]);
1475
1476        let reduced = run_partial_evaluator_local(&expr).unwrap().new_expression;
1477        let Expr::And(_, operands) = reduced else {
1478            panic!("expected And, got {reduced}");
1479        };
1480        let list = Moo::unwrap_or_clone(operands).unwrap_list().unwrap();
1481        assert_eq!(list.len(), 1);
1482        assert!(matches!(
1483            &list[0],
1484            Expr::Gt(_, lhs, rhs)
1485                if matches!(rhs.as_ref(), Expr::Atomic(_, Atom::Literal(Lit::Int(9))))
1486                    && matches!(lhs.as_ref(), Expr::Atomic(_, Atom::Reference(_)))
1487        ));
1488    }
1489
1490    #[test]
1491    fn and_dominates_constant_upper_bounds_on_same_atom() {
1492        let x = atom_ref("x");
1493        let expr = and(vec![
1494            Expr::Leq(Metadata::new(), Moo::new(x.clone()), Moo::new(int_lit(8))),
1495            Expr::Leq(Metadata::new(), Moo::new(x), Moo::new(int_lit(4))),
1496        ]);
1497
1498        let reduced = run_partial_evaluator_local(&expr).unwrap().new_expression;
1499        let Expr::And(_, operands) = reduced else {
1500            panic!("expected And, got {reduced}");
1501        };
1502        let list = Moo::unwrap_or_clone(operands).unwrap_list().unwrap();
1503        assert_eq!(list.len(), 1);
1504        assert!(matches!(
1505            &list[0],
1506            Expr::Leq(_, _, rhs)
1507                if matches!(rhs.as_ref(), Expr::Atomic(_, Atom::Literal(Lit::Int(4))))
1508        ));
1509    }
1510
1511    #[test]
1512    fn and_does_not_merge_a_single_constant_bound() {
1513        let x = atom_ref("x");
1514        let expr = and(vec![Expr::Gt(
1515            Metadata::new(),
1516            Moo::new(x),
1517            Moo::new(int_lit(3)),
1518        )]);
1519        assert!(run_partial_evaluator_local(&expr).is_err());
1520    }
1521
1522    fn product(factors: Vec<Expr>) -> Expr {
1523        Expr::Product(Metadata::new(), Moo::new(into_matrix_expr![factors]))
1524    }
1525
1526    /// Local evaluation must not move a lone zero factor to the end of a product.
1527    ///
1528    /// That reshuffle undoes `reorder_product`'s constant-first form and causes an infinite
1529    /// rewrite loop on models such as `savilerow/diet` (`x[i] * 0`).
1530    #[test]
1531    fn local_product_does_not_reshuffle_zero_factor() {
1532        let x = atom_ref("x");
1533        let constant_first = product(vec![int_lit(0), x.clone()]);
1534        let variable_first = product(vec![x, int_lit(0)]);
1535        assert!(run_partial_evaluator_local(&constant_first).is_err());
1536        assert!(run_partial_evaluator_local(&variable_first).is_err());
1537    }
1538
1539    /// Deep evaluation still collapses a safe `0 * x` product to the literal zero.
1540    #[test]
1541    fn deep_product_collapses_safe_zero_factor() {
1542        let expr = product(vec![int_lit(0), atom_ref("x")]);
1543        let reduced = run_partial_evaluator(&expr).unwrap().new_expression;
1544        assert_eq!(reduced, int_lit(0));
1545    }
1546
1547    #[test]
1548    fn symbolic_cardinality_is_left_for_representation_rules() {
1549        let set = Expr::Atomic(
1550            Metadata::new(),
1551            Atom::Reference(crate::ast::Reference::new(DeclarationPtr::new_find(
1552                Name::user("s"),
1553                Domain::set(
1554                    crate::ast::SetAttr::new_max_size(2),
1555                    Domain::int(vec![Range::Bounded(1, 2)]),
1556                ),
1557            ))),
1558        );
1559        let cardinality = Expr::Card(Metadata::new(), Moo::new(set));
1560
1561        assert!(run_partial_evaluator_local(&cardinality).is_err());
1562    }
1563}