Skip to main content

formualizer_eval/
function_contract.rs

1//! Optional function-owned dependency contracts.
2//!
3//! These contracts describe how a function contributes dependencies for passive
4//! planning/FormulaPlane analysis. They are deliberately additive: functions
5//! that do not opt in keep the default conservative behavior and receive no
6//! dependency-summary optimization.
7
8#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
9pub enum FunctionDependencyClass {
10    /// Dependencies are the union of all scalar/value arguments.
11    StaticScalarAllArgs,
12    /// Dependencies are the union of finite scalar/range reduction inputs.
13    StaticReduction,
14    /// Dependencies are finite criteria ranges, optional value ranges, and
15    /// dependencies of criteria expressions.
16    CriteriaAggregation,
17}
18
19#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
20pub enum FunctionArgumentDependencyRole {
21    ScalarValue,
22    FiniteRangeValue,
23    ReductionValue,
24    CriteriaRange,
25    CriteriaExpression,
26    ValueRange,
27    LazyBranch,
28    LookupKey,
29    LookupTable,
30    LookupResultSelector,
31    ByReference,
32    LocalBindingName,
33    LocalBindingValue,
34    LambdaBody,
35    IgnoredLiteral,
36    Unsupported,
37}
38
39#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
40pub enum FunctionArityRule {
41    Exactly(usize),
42    AtLeast(usize),
43    OneOf(&'static [usize]),
44    EvenAtLeast(usize),
45    OddAtLeast(usize),
46}
47
48impl FunctionArityRule {
49    pub fn allows(self, arity: usize) -> bool {
50        match self {
51            Self::Exactly(expected) => arity == expected,
52            Self::AtLeast(min) => arity >= min,
53            Self::OneOf(allowed) => allowed.contains(&arity),
54            Self::EvenAtLeast(min) => arity >= min && arity.is_multiple_of(2),
55            Self::OddAtLeast(min) => arity >= min && !arity.is_multiple_of(2),
56        }
57    }
58}
59
60#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
61pub enum CriteriaValueRange {
62    /// No separate value range; the function only contributes criteria ranges
63    /// and criteria-expression dependencies.
64    None,
65    /// A fixed argument index is the value/sum/average range.
66    Fixed(usize),
67    /// The value range is optional. If omitted, the criteria range at
68    /// `fallback_criteria_range_index` is also the value range.
69    Optional {
70        provided_index: usize,
71        fallback_criteria_range_index: usize,
72    },
73}
74
75#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
76pub struct CriteriaAggregationDependencyContract {
77    pub value_range: CriteriaValueRange,
78    pub first_criteria_pair: usize,
79}
80
81#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
82pub enum FunctionArgumentDependencyContract {
83    AllArgs(FunctionArgumentDependencyRole),
84    Variadic(FunctionArgumentDependencyRole),
85    CriteriaPairs(CriteriaAggregationDependencyContract),
86}
87
88#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
89pub struct FunctionDependencyContract {
90    pub class: FunctionDependencyClass,
91    pub arity: FunctionArityRule,
92    pub arguments: FunctionArgumentDependencyContract,
93}
94
95impl FunctionDependencyContract {
96    pub fn static_scalar_all_args(arity: usize) -> Option<Self> {
97        Self {
98            class: FunctionDependencyClass::StaticScalarAllArgs,
99            arity: FunctionArityRule::Exactly(1),
100            arguments: FunctionArgumentDependencyContract::AllArgs(
101                FunctionArgumentDependencyRole::ScalarValue,
102            ),
103        }
104        .for_arity(arity)
105    }
106
107    pub fn static_reduction(arity: usize, min_args: usize) -> Option<Self> {
108        Self {
109            class: FunctionDependencyClass::StaticReduction,
110            arity: FunctionArityRule::AtLeast(min_args),
111            arguments: FunctionArgumentDependencyContract::Variadic(
112                FunctionArgumentDependencyRole::ReductionValue,
113            ),
114        }
115        .for_arity(arity)
116    }
117
118    pub fn criteria_aggregation(
119        arity: usize,
120        arity_rule: FunctionArityRule,
121        value_range: CriteriaValueRange,
122        first_criteria_pair: usize,
123    ) -> Option<Self> {
124        Self {
125            class: FunctionDependencyClass::CriteriaAggregation,
126            arity: arity_rule,
127            arguments: FunctionArgumentDependencyContract::CriteriaPairs(
128                CriteriaAggregationDependencyContract {
129                    value_range,
130                    first_criteria_pair,
131                },
132            ),
133        }
134        .for_arity(arity)
135    }
136
137    pub fn for_arity(self, arity: usize) -> Option<Self> {
138        self.arity.allows(arity).then_some(self)
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::function::Function;
146    use crate::traits::{ArgumentHandle, FunctionContext};
147    use formualizer_common::ExcelError;
148
149    struct NoOptInFn;
150
151    impl Function for NoOptInFn {
152        fn name(&self) -> &'static str {
153            "NO_OPT_IN"
154        }
155
156        fn eval<'a, 'b, 'c>(
157            &self,
158            _args: &'c [ArgumentHandle<'a, 'b>],
159            _ctx: &dyn FunctionContext<'b>,
160        ) -> Result<crate::traits::CalcValue<'b>, ExcelError> {
161            unreachable!("contract tests never evaluate")
162        }
163    }
164
165    #[test]
166    fn default_function_dependency_contract_is_conservative_none() {
167        let function = NoOptInFn;
168
169        assert_eq!(function.dependency_contract(0), None);
170        assert_eq!(function.dependency_contract(1), None);
171        assert_eq!(function.dependency_contract(3), None);
172    }
173
174    #[test]
175    fn arity_rules_are_explicit_and_bounded() {
176        assert!(FunctionArityRule::Exactly(1).allows(1));
177        assert!(!FunctionArityRule::Exactly(1).allows(0));
178        assert!(FunctionArityRule::AtLeast(0).allows(0));
179        assert!(FunctionArityRule::AtLeast(1).allows(3));
180        assert!(!FunctionArityRule::AtLeast(2).allows(1));
181        assert!(FunctionArityRule::OneOf(&[2, 3]).allows(3));
182        assert!(!FunctionArityRule::OneOf(&[2, 3]).allows(4));
183        assert!(FunctionArityRule::EvenAtLeast(2).allows(4));
184        assert!(!FunctionArityRule::EvenAtLeast(2).allows(3));
185        assert!(FunctionArityRule::OddAtLeast(3).allows(5));
186        assert!(!FunctionArityRule::OddAtLeast(3).allows(4));
187    }
188
189    #[test]
190    fn constructors_return_none_for_unsupported_arities() {
191        assert!(FunctionDependencyContract::static_scalar_all_args(1).is_some());
192        assert_eq!(FunctionDependencyContract::static_scalar_all_args(2), None);
193
194        assert!(FunctionDependencyContract::static_reduction(0, 0).is_some());
195        assert_eq!(FunctionDependencyContract::static_reduction(0, 1), None);
196
197        assert!(
198            FunctionDependencyContract::criteria_aggregation(
199                4,
200                FunctionArityRule::EvenAtLeast(2),
201                CriteriaValueRange::None,
202                0,
203            )
204            .is_some()
205        );
206        assert_eq!(
207            FunctionDependencyContract::criteria_aggregation(
208                3,
209                FunctionArityRule::EvenAtLeast(2),
210                CriteriaValueRange::None,
211                0,
212            ),
213            None
214        );
215    }
216
217    #[test]
218    fn selected_builtin_opt_ins_are_colocated_and_arity_gated() {
219        use crate::builtins::math::aggregate::{AverageFn, SumFn};
220        use crate::builtins::math::criteria_aggregates::{CountIfsFn, SumIfFn, SumIfsFn};
221        use crate::builtins::math::numeric::AbsFn;
222
223        let abs = AbsFn;
224        assert_eq!(
225            abs.dependency_contract(1).map(|contract| contract.class),
226            Some(FunctionDependencyClass::StaticScalarAllArgs)
227        );
228        assert_eq!(abs.dependency_contract(2), None);
229
230        let sum = SumFn;
231        assert_eq!(
232            sum.dependency_contract(0).map(|contract| contract.class),
233            Some(FunctionDependencyClass::StaticReduction)
234        );
235
236        let average = AverageFn;
237        assert_eq!(average.dependency_contract(0), None);
238        assert_eq!(
239            average
240                .dependency_contract(1)
241                .map(|contract| contract.class),
242            Some(FunctionDependencyClass::StaticReduction)
243        );
244
245        let countifs = CountIfsFn;
246        assert!(countifs.dependency_contract(2).is_some());
247        assert!(countifs.dependency_contract(4).is_some());
248        assert_eq!(countifs.dependency_contract(3), None);
249
250        let sumif = SumIfFn;
251        let contract = sumif.dependency_contract(3).expect("SUMIF arity 3");
252        assert_eq!(contract.class, FunctionDependencyClass::CriteriaAggregation);
253        assert_eq!(
254            contract.arguments,
255            FunctionArgumentDependencyContract::CriteriaPairs(
256                CriteriaAggregationDependencyContract {
257                    value_range: CriteriaValueRange::Optional {
258                        provided_index: 2,
259                        fallback_criteria_range_index: 0,
260                    },
261                    first_criteria_pair: 0,
262                }
263            )
264        );
265
266        let sumifs = SumIfsFn;
267        assert!(sumifs.dependency_contract(3).is_some());
268        assert!(sumifs.dependency_contract(5).is_some());
269        assert_eq!(sumifs.dependency_contract(4), None);
270    }
271}