Skip to main content

formualizer_eval/
function_contract.rs

1//! Function-owned dependency and semantic contracts.
2//!
3//! Dependency precision remains optional. Semantic contracts classify call-site
4//! behavior without making function names an eligibility authority.
5
6use crate::function::FnCaps;
7
8#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
9pub enum FunctionDependencySemantics {
10    RecursiveSyntacticArgs,
11    Dynamic,
12    Unsupported,
13}
14
15#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
16pub enum FunctionEvaluationSemantics {
17    Eager,
18    ShortCircuit,
19}
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
22pub enum FunctionResultSemantics {
23    ScalarValue,
24    MayReturnReference,
25    MaySpill,
26    MayReturnReferenceAndSpill,
27    Unknown,
28}
29
30impl FunctionResultSemantics {
31    pub fn from_capabilities(may_return_reference: bool, may_spill: bool) -> Self {
32        match (may_return_reference, may_spill) {
33            (false, false) => Self::ScalarValue,
34            (true, false) => Self::MayReturnReference,
35            (false, true) => Self::MaySpill,
36            (true, true) => Self::MayReturnReferenceAndSpill,
37        }
38    }
39
40    pub fn may_return_reference(self) -> bool {
41        matches!(
42            self,
43            Self::MayReturnReference | Self::MayReturnReferenceAndSpill
44        )
45    }
46
47    pub fn may_spill(self) -> bool {
48        matches!(self, Self::MaySpill | Self::MayReturnReferenceAndSpill)
49    }
50}
51
52#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
53pub enum FunctionEnvironmentSemantics {
54    None,
55    LocalBindings,
56    Unknown,
57}
58
59#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
60pub enum FunctionContextDependence {
61    None,
62    PlacementDependent,
63    WorkbookMetadata,
64    LocaleOrConfiguration,
65    Unsupported,
66}
67
68#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
69pub struct FunctionSemanticContract {
70    pub dependency: FunctionDependencySemantics,
71    pub evaluation: FunctionEvaluationSemantics,
72    pub result: FunctionResultSemantics,
73    pub environment: FunctionEnvironmentSemantics,
74    pub context: FunctionContextDependence,
75    pub precision: Option<FunctionDependencyContract>,
76}
77
78impl FunctionSemanticContract {
79    pub fn trusted_builtin_default(precision: Option<FunctionDependencyContract>) -> Self {
80        Self {
81            dependency: FunctionDependencySemantics::RecursiveSyntacticArgs,
82            evaluation: FunctionEvaluationSemantics::Eager,
83            result: FunctionResultSemantics::ScalarValue,
84            environment: FunctionEnvironmentSemantics::None,
85            context: FunctionContextDependence::None,
86            precision,
87        }
88    }
89}
90
91#[derive(Clone, Debug, PartialEq, Eq, Hash)]
92pub struct FunctionSemanticIdentity {
93    pub(crate) namespace: String,
94    pub(crate) canonical_name: String,
95    pub(crate) generation: u64,
96    pub(crate) caps: FnCaps,
97    pub(crate) contract: FunctionSemanticContract,
98    pub(crate) argument_by_ref: Vec<bool>,
99}
100
101impl FunctionSemanticIdentity {
102    pub(crate) fn encode(&self) -> Vec<u8> {
103        let mut out = Vec::new();
104        push_bytes(&mut out, self.namespace.as_bytes());
105        push_bytes(&mut out, self.canonical_name.as_bytes());
106        out.extend_from_slice(&self.generation.to_le_bytes());
107        out.extend_from_slice(&self.caps.bits().to_le_bytes());
108        out.push(self.contract.dependency as u8);
109        out.push(self.contract.evaluation as u8);
110        out.push(self.contract.result as u8);
111        out.push(self.contract.environment as u8);
112        out.push(self.contract.context as u8);
113        match self.contract.precision {
114            Some(precision) => {
115                out.push(1);
116                encode_precision(&mut out, precision);
117            }
118            None => out.push(0),
119        }
120        out.extend_from_slice(&(self.argument_by_ref.len() as u64).to_le_bytes());
121        out.extend(self.argument_by_ref.iter().map(|value| u8::from(*value)));
122        out
123    }
124}
125
126fn push_bytes(out: &mut Vec<u8>, bytes: &[u8]) {
127    out.extend_from_slice(&(bytes.len() as u64).to_le_bytes());
128    out.extend_from_slice(bytes);
129}
130
131fn encode_precision(out: &mut Vec<u8>, precision: FunctionDependencyContract) {
132    out.push(precision.class as u8);
133    match precision.arity {
134        FunctionArityRule::Exactly(value) => encode_arity(out, 0, value),
135        FunctionArityRule::AtLeast(value) => encode_arity(out, 1, value),
136        FunctionArityRule::OneOf(values) => {
137            out.push(2);
138            out.extend_from_slice(&(values.len() as u64).to_le_bytes());
139            for value in values {
140                out.extend_from_slice(&(*value as u64).to_le_bytes());
141            }
142        }
143        FunctionArityRule::EvenAtLeast(value) => encode_arity(out, 3, value),
144        FunctionArityRule::OddAtLeast(value) => encode_arity(out, 4, value),
145    }
146    match precision.arguments {
147        FunctionArgumentDependencyContract::AllArgs(role) => encode_role(out, 0, role),
148        FunctionArgumentDependencyContract::Variadic(role) => encode_role(out, 1, role),
149        FunctionArgumentDependencyContract::CriteriaPairs(criteria) => {
150            out.push(2);
151            match criteria.value_range {
152                CriteriaValueRange::None => out.push(0),
153                CriteriaValueRange::Fixed(index) => encode_arity(out, 1, index),
154                CriteriaValueRange::Optional {
155                    provided_index,
156                    fallback_criteria_range_index,
157                } => {
158                    encode_arity(out, 2, provided_index);
159                    out.extend_from_slice(&(fallback_criteria_range_index as u64).to_le_bytes());
160                }
161            }
162            out.extend_from_slice(&(criteria.first_criteria_pair as u64).to_le_bytes());
163        }
164        FunctionArgumentDependencyContract::LocalBindingPairs => out.push(3),
165        FunctionArgumentDependencyContract::LambdaParameters => out.push(4),
166    }
167}
168
169fn encode_arity(out: &mut Vec<u8>, tag: u8, value: usize) {
170    out.push(tag);
171    out.extend_from_slice(&(value as u64).to_le_bytes());
172}
173
174fn encode_role(out: &mut Vec<u8>, tag: u8, role: FunctionArgumentDependencyRole) {
175    out.push(tag);
176    out.push(role as u8);
177}
178
179#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
180pub enum FunctionDependencyClass {
181    /// Dependencies are the union of all scalar/value arguments.
182    StaticScalarAllArgs,
183    /// Dependencies are the union of finite scalar/range reduction inputs.
184    StaticReduction,
185    /// Dependencies are finite criteria ranges, optional value ranges, and
186    /// dependencies of criteria expressions.
187    CriteriaAggregation,
188}
189
190#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
191pub enum FunctionArgumentDependencyRole {
192    ScalarValue,
193    FiniteRangeValue,
194    ReductionValue,
195    CriteriaRange,
196    CriteriaExpression,
197    ValueRange,
198    LazyBranch,
199    LookupKey,
200    LookupTable,
201    LookupResultSelector,
202    ByReference,
203    LocalBindingName,
204    LocalBindingValue,
205    LambdaBody,
206    IgnoredLiteral,
207    Unsupported,
208}
209
210#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
211pub enum FunctionArityRule {
212    Exactly(usize),
213    AtLeast(usize),
214    OneOf(&'static [usize]),
215    EvenAtLeast(usize),
216    OddAtLeast(usize),
217}
218
219impl FunctionArityRule {
220    pub fn allows(self, arity: usize) -> bool {
221        match self {
222            Self::Exactly(expected) => arity == expected,
223            Self::AtLeast(min) => arity >= min,
224            Self::OneOf(allowed) => allowed.contains(&arity),
225            Self::EvenAtLeast(min) => arity >= min && arity.is_multiple_of(2),
226            Self::OddAtLeast(min) => arity >= min && !arity.is_multiple_of(2),
227        }
228    }
229}
230
231#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
232pub enum CriteriaValueRange {
233    /// No separate value range; the function only contributes criteria ranges
234    /// and criteria-expression dependencies.
235    None,
236    /// A fixed argument index is the value/sum/average range.
237    Fixed(usize),
238    /// The value range is optional. If omitted, the criteria range at
239    /// `fallback_criteria_range_index` is also the value range.
240    Optional {
241        provided_index: usize,
242        fallback_criteria_range_index: usize,
243    },
244}
245
246#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
247pub struct CriteriaAggregationDependencyContract {
248    pub value_range: CriteriaValueRange,
249    pub first_criteria_pair: usize,
250}
251
252#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
253pub enum FunctionArgumentDependencyContract {
254    AllArgs(FunctionArgumentDependencyRole),
255    Variadic(FunctionArgumentDependencyRole),
256    CriteriaPairs(CriteriaAggregationDependencyContract),
257    LocalBindingPairs,
258    LambdaParameters,
259}
260
261#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
262pub struct FunctionDependencyContract {
263    pub class: FunctionDependencyClass,
264    pub arity: FunctionArityRule,
265    pub arguments: FunctionArgumentDependencyContract,
266}
267
268impl FunctionDependencyContract {
269    pub fn static_scalar_all_args(arity: usize) -> Option<Self> {
270        Self {
271            class: FunctionDependencyClass::StaticScalarAllArgs,
272            arity: FunctionArityRule::Exactly(1),
273            arguments: FunctionArgumentDependencyContract::AllArgs(
274                FunctionArgumentDependencyRole::ScalarValue,
275            ),
276        }
277        .for_arity(arity)
278    }
279
280    pub fn static_reduction(arity: usize, min_args: usize) -> Option<Self> {
281        Self {
282            class: FunctionDependencyClass::StaticReduction,
283            arity: FunctionArityRule::AtLeast(min_args),
284            arguments: FunctionArgumentDependencyContract::Variadic(
285                FunctionArgumentDependencyRole::ReductionValue,
286            ),
287        }
288        .for_arity(arity)
289    }
290
291    pub fn criteria_aggregation(
292        arity: usize,
293        arity_rule: FunctionArityRule,
294        value_range: CriteriaValueRange,
295        first_criteria_pair: usize,
296    ) -> Option<Self> {
297        Self {
298            class: FunctionDependencyClass::CriteriaAggregation,
299            arity: arity_rule,
300            arguments: FunctionArgumentDependencyContract::CriteriaPairs(
301                CriteriaAggregationDependencyContract {
302                    value_range,
303                    first_criteria_pair,
304                },
305            ),
306        }
307        .for_arity(arity)
308    }
309
310    pub fn for_arity(self, arity: usize) -> Option<Self> {
311        self.arity.allows(arity).then_some(self)
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use crate::function::Function;
319    use crate::traits::{ArgumentHandle, FunctionContext};
320    use formualizer_common::ExcelError;
321
322    struct NoOptInFn;
323
324    impl Function for NoOptInFn {
325        fn name(&self) -> &'static str {
326            "NO_OPT_IN"
327        }
328
329        fn eval<'a, 'b, 'c>(
330            &self,
331            _args: &'c [ArgumentHandle<'a, 'b>],
332            _ctx: &dyn FunctionContext<'b>,
333        ) -> Result<crate::traits::CalcValue<'b>, ExcelError> {
334            unreachable!("contract tests never evaluate")
335        }
336    }
337
338    #[test]
339    fn default_function_dependency_contract_is_conservative_none() {
340        let function = NoOptInFn;
341
342        assert_eq!(function.dependency_contract(0), None);
343        assert_eq!(function.dependency_contract(1), None);
344        assert_eq!(function.dependency_contract(3), None);
345    }
346
347    #[test]
348    fn arity_rules_are_explicit_and_bounded() {
349        assert!(FunctionArityRule::Exactly(1).allows(1));
350        assert!(!FunctionArityRule::Exactly(1).allows(0));
351        assert!(FunctionArityRule::AtLeast(0).allows(0));
352        assert!(FunctionArityRule::AtLeast(1).allows(3));
353        assert!(!FunctionArityRule::AtLeast(2).allows(1));
354        assert!(FunctionArityRule::OneOf(&[2, 3]).allows(3));
355        assert!(!FunctionArityRule::OneOf(&[2, 3]).allows(4));
356        assert!(FunctionArityRule::EvenAtLeast(2).allows(4));
357        assert!(!FunctionArityRule::EvenAtLeast(2).allows(3));
358        assert!(FunctionArityRule::OddAtLeast(3).allows(5));
359        assert!(!FunctionArityRule::OddAtLeast(3).allows(4));
360    }
361
362    #[test]
363    fn constructors_return_none_for_unsupported_arities() {
364        assert!(FunctionDependencyContract::static_scalar_all_args(1).is_some());
365        assert_eq!(FunctionDependencyContract::static_scalar_all_args(2), None);
366
367        assert!(FunctionDependencyContract::static_reduction(0, 0).is_some());
368        assert_eq!(FunctionDependencyContract::static_reduction(0, 1), None);
369
370        assert!(
371            FunctionDependencyContract::criteria_aggregation(
372                4,
373                FunctionArityRule::EvenAtLeast(2),
374                CriteriaValueRange::None,
375                0,
376            )
377            .is_some()
378        );
379        assert_eq!(
380            FunctionDependencyContract::criteria_aggregation(
381                3,
382                FunctionArityRule::EvenAtLeast(2),
383                CriteriaValueRange::None,
384                0,
385            ),
386            None
387        );
388    }
389
390    #[test]
391    fn selected_builtin_opt_ins_are_colocated_and_arity_gated() {
392        use crate::builtins::math::aggregate::{AverageFn, SumFn};
393        use crate::builtins::math::criteria_aggregates::{CountIfsFn, SumIfFn, SumIfsFn};
394        use crate::builtins::math::numeric::AbsFn;
395
396        let abs = AbsFn;
397        assert_eq!(
398            abs.dependency_contract(1).map(|contract| contract.class),
399            Some(FunctionDependencyClass::StaticScalarAllArgs)
400        );
401        assert_eq!(abs.dependency_contract(2), None);
402
403        let sum = SumFn;
404        assert_eq!(
405            sum.dependency_contract(0).map(|contract| contract.class),
406            Some(FunctionDependencyClass::StaticReduction)
407        );
408
409        let average = AverageFn;
410        assert_eq!(average.dependency_contract(0), None);
411        assert_eq!(
412            average
413                .dependency_contract(1)
414                .map(|contract| contract.class),
415            Some(FunctionDependencyClass::StaticReduction)
416        );
417
418        let countifs = CountIfsFn;
419        assert!(countifs.dependency_contract(2).is_some());
420        assert!(countifs.dependency_contract(4).is_some());
421        assert_eq!(countifs.dependency_contract(3), None);
422
423        let sumif = SumIfFn;
424        let contract = sumif.dependency_contract(3).expect("SUMIF arity 3");
425        assert_eq!(contract.class, FunctionDependencyClass::CriteriaAggregation);
426        assert_eq!(
427            contract.arguments,
428            FunctionArgumentDependencyContract::CriteriaPairs(
429                CriteriaAggregationDependencyContract {
430                    value_range: CriteriaValueRange::Optional {
431                        provided_index: 2,
432                        fallback_criteria_range_index: 0,
433                    },
434                    first_criteria_pair: 0,
435                }
436            )
437        );
438
439        let sumifs = SumIfsFn;
440        assert!(sumifs.dependency_contract(3).is_some());
441        assert!(sumifs.dependency_contract(5).is_some());
442        assert_eq!(sumifs.dependency_contract(4), None);
443    }
444}