1
//! Normalising rules for weighted sums.
2
//!
3
//! Weighted sums are sums in the form c1*v1 + c2*v2 + ..., where cx are literals, and vx variable
4
//! references.
5

            
6
use std::collections::{BTreeMap, BTreeSet};
7

            
8
use conjure_cp::ast::{AbstractLiteral, Reference};
9
use conjure_cp::essence_expr;
10
use conjure_cp::rule_engine::register_rule;
11
use conjure_cp::{
12
    ast::Metadata,
13
    ast::{Atom, Expression as Expr, IntVal, Literal as Lit, Moo, Range, SymbolTable},
14
    into_matrix_expr,
15
    rule_engine::{ApplicationError::RuleNotApplicable, ApplicationResult, RuleEffect},
16
};
17

            
18
/// Collects like terms in a weighted sum.
19
///
20
/// For some variable v, and constants cx,
21
///
22
/// ```plain
23
/// (c1 * v)  + .. + (c2 * v) + ... ~> ((c1 + c2) * v) + ...
24
/// ```
25
#[register_rule("Base", 8400, [Sum])]
26
1073936
fn collect_like_terms(expr: &Expr, _: &SymbolTable) -> ApplicationResult {
27
1073936
    let Expr::Sum(meta, exprs) = expr else {
28
4011
        return Err(RuleNotApplicable);
29
    };
30
1069925
    let exprs = expr_list_elements(exprs.as_ref()).ok_or(RuleNotApplicable)?;
31

            
32
1025424
    if !has_duplicate_weighted_reference(exprs) {
33
1025424
        return Err(RuleNotApplicable);
34
    }
35

            
36
    // Store:
37
    //  * map variable -> coefficient for weighted sum terms
38
    //  * a list of non-weighted sum terms
39

            
40
    #[allow(clippy::mutable_key_type)]
41
    let mut weighted_terms: BTreeMap<Reference, i32> = BTreeMap::new();
42
    let mut other_terms: Vec<Expr> = Vec::new();
43

            
44
    // Assume valid terms are in form constant*variable, as reorder_product and partial_eval
45
    // should've already ran.
46

            
47
    for expr in exprs.iter() {
48
        if let Some((re, coefficient)) = weighted_term(expr) {
49
            let curr_weight = weighted_terms.get(re).unwrap_or(&0);
50
            weighted_terms.insert(re.clone(), curr_weight + coefficient);
51
        } else {
52
            other_terms.push(expr.clone());
53
        }
54
    }
55

            
56
    // this rule has done nothing.
57
    if weighted_terms.is_empty() {
58
        return Err(RuleNotApplicable);
59
    }
60

            
61
    let mut new_exprs = vec![];
62
    for (re, coefficient) in weighted_terms {
63
        let atom = Expr::Atomic(Metadata::new(), Atom::Reference(re));
64
        new_exprs.push(essence_expr!(&atom * &coefficient));
65
    }
66

            
67
    new_exprs.extend(other_terms);
68

            
69
    // no change
70
    if new_exprs.len() == exprs.len() {
71
        return Err(RuleNotApplicable);
72
    }
73

            
74
    Ok(RuleEffect::pure(Expr::Sum(
75
        meta.clone(),
76
        Moo::new(into_matrix_expr![new_exprs]),
77
    )))
78
1073936
}
79

            
80
1025424
fn has_duplicate_weighted_reference(exprs: &[Expr]) -> bool {
81
1025424
    let mut seen = BTreeSet::new();
82
1025424
    for expr in exprs {
83
7078119
        let Some((reference, _)) = weighted_term(expr) else {
84
7078119
            continue;
85
        };
86
        if !seen.insert(reference.id()) {
87
            return true;
88
        }
89
    }
90

            
91
1025424
    false
92
1025424
}
93

            
94
7078119
fn weighted_term(expr: &Expr) -> Option<(&Reference, i32)> {
95
7078119
    let Expr::Product(_, exprs) = expr else {
96
6988033
        return None;
97
    };
98

            
99
90086
    match expr_list_elements(exprs.as_ref())? {
100
        // todo (gs248) It would be nice to generate these destructures by macro, like `essence_expr!` but in reverse
101
        // -c*v
102
        [Expr::Atomic(_, Atom::Reference(re)), Expr::Neg(_, e3)] => {
103
            let Expr::Atomic(_, Atom::Literal(Lit::Int(l))) = e3.as_ref() else {
104
                return None;
105
            };
106
            Some((re, -*l))
107
        }
108

            
109
        // c*v
110
        [
111
            Expr::Atomic(_, Atom::Reference(re)),
112
            Expr::Atomic(_, Atom::Literal(Lit::Int(l))),
113
        ] => Some((re, *l)),
114

            
115
90086
        _ => None,
116
    }
117
7078119
}
118

            
119
1160011
fn expr_list_elements(expr: &Expr) -> Option<&[Expr]> {
120
1160011
    match expr {
121
        Expr::AbstractLiteral(_, AbstractLiteral::Matrix(elems, domain))
122
1125533
            if domain.as_int().is_some_and(|ranges| {
123
1125533
                matches!(ranges.as_slice(), [Range::UnboundedR(IntVal::Const(1))])
124
1125533
            }) =>
125
        {
126
1115510
            Some(elems)
127
        }
128
44501
        _ => None,
129
    }
130
1160011
}