Skip to main content

problemreductions/
expr.rs

1//! Symbolic expression integration for the problem-reduction domain.
2
3pub use num_bigint::BigInt;
4use num_rational::BigRational;
5#[cfg(test)]
6use num_traits::FromPrimitive;
7use num_traits::{One, Signed, ToPrimitive, Zero};
8pub use problemreductions_expr::{
9    Expr, ExprNode, ExprNodeId, ParseError, SubstitutionError, Symbol,
10};
11use std::cmp::Ordering;
12use std::collections::{BTreeMap, HashMap};
13use std::fmt;
14
15use crate::types::ProblemParameters;
16
17/// Algebraic facts computed once from the shared expression DAG and consumed by
18/// exact-size evaluation and asymptotic-growth projection.
19#[derive(Clone, Debug)]
20pub(crate) struct AlgebraicAnalysis {
21    facts: HashMap<ExprNodeId, AlgebraicFacts>,
22}
23
24#[derive(Clone, Debug)]
25pub(crate) struct AlgebraicFacts {
26    pub(crate) is_constant: bool,
27    pub(crate) exact_rational: Option<BigRational>,
28    pub(crate) linear: Option<BTreeMap<Symbol, BigRational>>,
29    pub(crate) constant_domain: Option<bool>,
30    pub(crate) sign: Option<Ordering>,
31    pub(crate) cmp_one: Option<Ordering>,
32}
33
34impl AlgebraicAnalysis {
35    pub(crate) fn new(expressions: &[&Expr]) -> Self {
36        let mut facts = HashMap::new();
37        for expression in expressions {
38            analyze_algebraic(expression, &mut facts);
39        }
40        Self { facts }
41    }
42
43    pub(crate) fn facts(&self, expression: &Expr) -> &AlgebraicFacts {
44        &self.facts[&expression.node_identity()]
45    }
46}
47
48fn analyze_algebraic(
49    expression: &Expr,
50    memo: &mut HashMap<ExprNodeId, AlgebraicFacts>,
51) -> AlgebraicFacts {
52    if let Some(facts) = memo.get(&expression.node_identity()) {
53        return facts.clone();
54    }
55    let facts = match expression.node() {
56        ExprNode::Const(value) => AlgebraicFacts {
57            is_constant: true,
58            exact_rational: Some(value.clone()),
59            linear: Some(BTreeMap::new()),
60            constant_domain: Some(true),
61            sign: Some(value.cmp(&BigRational::from_integer(0.into()))),
62            cmp_one: Some(value.cmp(&BigRational::from_integer(1.into()))),
63        },
64        ExprNode::Var(symbol) => AlgebraicFacts {
65            is_constant: false,
66            exact_rational: None,
67            linear: Some(BTreeMap::from([(
68                symbol.clone(),
69                BigRational::from_integer(1.into()),
70            )])),
71            constant_domain: None,
72            sign: None,
73            cmp_one: None,
74        },
75        ExprNode::Add(values) => {
76            let children = values
77                .iter()
78                .map(|value| analyze_algebraic(value, memo))
79                .collect::<Vec<_>>();
80            let is_constant = children.iter().all(|facts| facts.is_constant);
81            AlgebraicFacts {
82                is_constant,
83                exact_rational: sum_exact(&children),
84                linear: sum_linear(&children),
85                constant_domain: is_constant.then(|| all_domains(&children)).flatten(),
86                sign: is_constant.then(|| sum_sign(&children)).flatten(),
87                cmp_one: None,
88            }
89        }
90        ExprNode::Mul(values) => {
91            let children = values
92                .iter()
93                .map(|value| analyze_algebraic(value, memo))
94                .collect::<Vec<_>>();
95            let is_constant = children.iter().all(|facts| facts.is_constant);
96            let exact_rational = product_exact(&children);
97            AlgebraicFacts {
98                is_constant,
99                linear: product_linear(&children),
100                constant_domain: is_constant.then(|| all_domains(&children)).flatten(),
101                sign: is_constant.then(|| product_sign(&children)).flatten(),
102                cmp_one: exact_rational
103                    .as_ref()
104                    .map(|value| value.cmp(&BigRational::from_integer(1.into()))),
105                exact_rational,
106            }
107        }
108        ExprNode::Pow(base, exponent) => {
109            let base = analyze_algebraic(base, memo);
110            let exponent = analyze_algebraic(exponent, memo);
111            let is_constant = base.is_constant && exponent.is_constant;
112            let exact_rational = match (
113                base.exact_rational.as_ref(),
114                exponent.exact_rational.as_ref(),
115            ) {
116                (Some(base), Some(exponent)) if exponent == &-BigRational::one() => {
117                    (!base.is_zero()).then(|| base.recip())
118                }
119                _ => None,
120            };
121            let domain = if is_constant {
122                power_domain(&base, &exponent)
123            } else {
124                None
125            };
126            let sign = domain
127                .is_some_and(|defined| defined)
128                .then(|| power_sign(&base, &exponent))
129                .flatten();
130            let cmp_one = domain
131                .is_some_and(|defined| defined)
132                .then(|| power_cmp_one(&base, &exponent))
133                .flatten();
134            AlgebraicFacts {
135                is_constant,
136                exact_rational,
137                linear: is_constant.then(BTreeMap::new),
138                constant_domain: domain,
139                sign,
140                cmp_one,
141            }
142        }
143        ExprNode::Exp(value) => {
144            let value = analyze_algebraic(value, memo);
145            let domain = value.is_constant.then_some(value.constant_domain).flatten();
146            AlgebraicFacts {
147                is_constant: value.is_constant,
148                exact_rational: value
149                    .exact_rational
150                    .as_ref()
151                    .filter(|value| value.is_zero())
152                    .map(|_| BigRational::from_integer(1.into())),
153                linear: value.is_constant.then(BTreeMap::new),
154                constant_domain: domain,
155                sign: domain
156                    .is_some_and(|defined| defined)
157                    .then_some(Ordering::Greater),
158                cmp_one: value.sign,
159            }
160        }
161        ExprNode::Log(value) => {
162            let value = analyze_algebraic(value, memo);
163            let domain = if value.is_constant {
164                value
165                    .constant_domain
166                    .map(|defined| defined && value.sign == Some(Ordering::Greater))
167            } else {
168                None
169            };
170            AlgebraicFacts {
171                is_constant: value.is_constant,
172                exact_rational: value
173                    .exact_rational
174                    .as_ref()
175                    .filter(|value| value.is_one())
176                    .map(|_| BigRational::from_integer(0.into())),
177                linear: value.is_constant.then(BTreeMap::new),
178                constant_domain: domain,
179                sign: domain
180                    .is_some_and(|defined| defined)
181                    .then_some(value.cmp_one)
182                    .flatten(),
183                cmp_one: None,
184            }
185        }
186        ExprNode::Factorial(value) => {
187            let value = analyze_algebraic(value, memo);
188            let valid = value
189                .exact_rational
190                .as_ref()
191                .map(|value| value.is_integer() && !value.is_negative());
192            AlgebraicFacts {
193                is_constant: value.is_constant,
194                exact_rational: None,
195                linear: value.is_constant.then(BTreeMap::new),
196                constant_domain: value.is_constant.then_some(valid).flatten(),
197                sign: valid
198                    .is_some_and(|valid| valid)
199                    .then_some(Ordering::Greater),
200                cmp_one: valid.and_then(|valid| {
201                    valid.then(|| {
202                        if value
203                            .exact_rational
204                            .as_ref()
205                            .is_some_and(|value| value <= &BigRational::from_integer(1.into()))
206                        {
207                            Ordering::Equal
208                        } else {
209                            Ordering::Greater
210                        }
211                    })
212                }),
213            }
214        }
215    };
216    memo.insert(expression.node_identity(), facts.clone());
217    facts
218}
219
220fn sum_exact(children: &[AlgebraicFacts]) -> Option<BigRational> {
221    children.iter().try_fold(BigRational::zero(), |sum, child| {
222        Some(sum + child.exact_rational.as_ref()?)
223    })
224}
225
226fn product_exact(children: &[AlgebraicFacts]) -> Option<BigRational> {
227    children
228        .iter()
229        .try_fold(BigRational::one(), |product, child| {
230            Some(product * child.exact_rational.as_ref()?)
231        })
232}
233
234fn sum_linear(children: &[AlgebraicFacts]) -> Option<BTreeMap<Symbol, BigRational>> {
235    let mut result = BTreeMap::new();
236    for child in children {
237        for (symbol, coefficient) in child.linear.as_ref()? {
238            *result
239                .entry(symbol.clone())
240                .or_insert_with(BigRational::zero) += coefficient;
241        }
242    }
243    result.retain(|_, coefficient| !coefficient.is_zero());
244    Some(result)
245}
246
247fn product_linear(children: &[AlgebraicFacts]) -> Option<BTreeMap<Symbol, BigRational>> {
248    if children.iter().all(|child| child.is_constant) {
249        return Some(BTreeMap::new());
250    }
251    let mut coefficient = BigRational::one();
252    let mut linear = None;
253    for child in children {
254        if child.is_constant {
255            coefficient *= child.exact_rational.as_ref()?;
256        } else if linear.is_some() {
257            return None;
258        } else {
259            linear = Some(child.linear.clone()?);
260        }
261    }
262    let mut linear = linear?;
263    for value in linear.values_mut() {
264        *value *= &coefficient;
265    }
266    linear.retain(|_, value| !value.is_zero());
267    Some(linear)
268}
269
270fn all_domains(children: &[AlgebraicFacts]) -> Option<bool> {
271    let mut defined = true;
272    for child in children {
273        defined &= child.constant_domain?;
274    }
275    Some(defined)
276}
277
278fn sum_sign(children: &[AlgebraicFacts]) -> Option<Ordering> {
279    if let Some(value) = sum_exact(children) {
280        return Some(value.cmp(&BigRational::zero()));
281    }
282    let signs = children
283        .iter()
284        .map(|child| child.sign)
285        .collect::<Option<Vec<_>>>()?;
286    if signs.iter().all(|sign| *sign != Ordering::Less) {
287        Some(if signs.contains(&Ordering::Greater) {
288            Ordering::Greater
289        } else {
290            Ordering::Equal
291        })
292    } else if signs.iter().all(|sign| *sign != Ordering::Greater) {
293        Some(Ordering::Less)
294    } else {
295        None
296    }
297}
298
299fn product_sign(children: &[AlgebraicFacts]) -> Option<Ordering> {
300    let mut sign = Ordering::Greater;
301    for child in children {
302        match child.sign? {
303            Ordering::Equal => return Some(Ordering::Equal),
304            Ordering::Less => sign = sign.reverse(),
305            Ordering::Greater => {}
306        }
307    }
308    Some(sign)
309}
310
311fn power_domain(base: &AlgebraicFacts, exponent: &AlgebraicFacts) -> Option<bool> {
312    if !base.constant_domain? || !exponent.constant_domain? {
313        return Some(false);
314    }
315    match base.sign? {
316        Ordering::Greater => Some(true),
317        Ordering::Equal => Some(exponent.sign? == Ordering::Greater),
318        Ordering::Less => exponent
319            .exact_rational
320            .as_ref()
321            .map(|value| value.is_integer()),
322    }
323}
324
325fn power_sign(base: &AlgebraicFacts, exponent: &AlgebraicFacts) -> Option<Ordering> {
326    let exponent_value = exponent.exact_rational.as_ref()?;
327    if exponent_value.is_zero() {
328        return Some(Ordering::Greater);
329    }
330    match base.sign? {
331        Ordering::Greater => Some(Ordering::Greater),
332        Ordering::Equal => Some(Ordering::Equal),
333        Ordering::Less => {
334            let exponent = exponent_value.to_integer();
335            if (&exponent % 2u8).is_zero() {
336                Some(Ordering::Greater)
337            } else {
338                Some(Ordering::Less)
339            }
340        }
341    }
342}
343
344fn power_cmp_one(base: &AlgebraicFacts, exponent: &AlgebraicFacts) -> Option<Ordering> {
345    let exponent = exponent.exact_rational.as_ref()?;
346    if exponent.is_zero() || base.cmp_one == Some(Ordering::Equal) {
347        Some(Ordering::Equal)
348    } else if exponent.is_positive() {
349        base.cmp_one
350    } else {
351        base.cmp_one.map(Ordering::reverse)
352    }
353}
354
355/// Evaluate an expression numerically at an explicitly approximate boundary.
356pub fn evaluate_approximate(
357    expression: &Expr,
358    variables: &ProblemParameters,
359) -> Result<f64, ApproximationError> {
360    evaluate_approximate_inner(expression, variables, &mut HashMap::new())
361}
362
363fn evaluate_approximate_inner(
364    expression: &Expr,
365    variables: &ProblemParameters,
366    memo: &mut HashMap<ExprNodeId, f64>,
367) -> Result<f64, ApproximationError> {
368    if let Some(value) = memo.get(&expression.node_identity()) {
369        return Ok(*value);
370    }
371    let value = match expression.node() {
372        ExprNode::Const(value) => rational_to_f64(value),
373        ExprNode::Var(name) => variables
374            .get(name.as_str())
375            .map(|value| value as f64)
376            .ok_or_else(|| ApproximationError::MissingVariable(name.to_string())),
377        ExprNode::Add(values) => values.iter().try_fold(0.0, |sum, value| {
378            Ok(sum + evaluate_approximate_inner(value, variables, memo)?)
379        }),
380        ExprNode::Mul(values) => values.iter().try_fold(1.0, |product, value| {
381            Ok(product * evaluate_approximate_inner(value, variables, memo)?)
382        }),
383        ExprNode::Pow(base, exponent) => Ok(evaluate_approximate_inner(base, variables, memo)?
384            .powf(evaluate_approximate_inner(exponent, variables, memo)?)),
385        ExprNode::Exp(value) => Ok(evaluate_approximate_inner(value, variables, memo)?.exp()),
386        ExprNode::Log(value) => Ok(evaluate_approximate_inner(value, variables, memo)?.ln()),
387        ExprNode::Factorial(value) => {
388            approximate_factorial(evaluate_approximate_inner(value, variables, memo)?)
389        }
390    }?;
391    if !value.is_finite() {
392        return Err(ApproximationError::NonFiniteResult(expression.to_string()));
393    }
394    memo.insert(expression.node_identity(), value);
395    Ok(value)
396}
397
398/// Convert an approximation produced by the growth domain back to an exact AST constant.
399#[cfg(test)]
400pub(crate) fn expression_from_approximation(value: f64) -> Expr {
401    Expr::constant(
402        BigRational::from_f64(value)
403            .expect("growth-domain expression constants must be finite numbers"),
404    )
405}
406
407pub(crate) fn rational_to_f64(value: &BigRational) -> Result<f64, ApproximationError> {
408    value
409        .to_f64()
410        .filter(|value| value.is_finite())
411        .ok_or_else(|| ApproximationError::OutOfRange(value.to_string()))
412}
413
414pub(crate) fn approximate_factorial(value: f64) -> Result<f64, ApproximationError> {
415    if !value.is_finite() || value < 0.0 || value.fract() != 0.0 {
416        return Err(ApproximationError::InvalidFactorialArgument(
417            value.to_string(),
418        ));
419    }
420    if value > 170.0 {
421        Err(ApproximationError::NonFiniteResult(format!(
422            "factorial({value})"
423        )))
424    } else {
425        Ok((2..=value as u64).fold(1.0, |product, factor| product * factor as f64))
426    }
427}
428
429#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
430pub enum ApproximationError {
431    #[error("missing expression variable {0}")]
432    MissingVariable(String),
433    #[error("exact constant {0} is outside the f64 approximation domain")]
434    OutOfRange(String),
435    #[error("factorial argument must be a non-negative integer, found {0}")]
436    InvalidFactorialArgument(String),
437    #[error("expression {0} has no finite real approximation")]
438    NonFiniteResult(String),
439}
440
441/// Error returned when analyzing asymptotic behavior.
442#[derive(Clone, Debug, PartialEq, Eq)]
443pub enum AsymptoticAnalysisError {
444    Unsupported(String),
445}
446
447impl fmt::Display for AsymptoticAnalysisError {
448    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
449        match self {
450            Self::Unsupported(expression) => {
451                write!(formatter, "unsupported asymptotic expression: {expression}")
452            }
453        }
454    }
455}
456
457impl std::error::Error for AsymptoticAnalysisError {}
458
459#[cfg(test)]
460#[path = "unit_tests/expr.rs"]
461mod tests;