Do not evaluate IF's predicate twice#339
Conversation
|
Thanks for the PR and the well-constructed examples — you've hit on something we deliberated over quite a bit for 4.0. I'm going to decline this approach, though, because it reverses a decision we just committed to for the 4.0 release (currently at 4.0.0.pre). The short version: calculator.evaluate!('IF(x > 5, y, z)', x: 7, y: 1) #=> 1
For your On That said, your first complaint is completely legitimate: a heavy predicate really is executed twice under Thanks again for digging into this! |
Could you please explain what are the benefits of this decision? USER_ATTR_FUNC = ->(attr_name, default = 0) { Current.user.attributes[attr_name] || default }
calculator = Dentaku::Calculator.new
calculator.add_function(:user_attr, :numeric, USER_ATTR_FUNC)
# Build dependencies for formula to send through API
formula = "IF(user_attr('level') > 50, high_level_value, low_level_value)" # we are storing formulas in db, but here I wil omit that
Current.user = User.new # mock-user
deps = calculator.dependencies(formula)
# we are expecting ["high_level_value", "low_level_value"] so other services will know what should be provided
# but in reality we will get only ["low_level_value"] since function will fallback to default 0 value for mock-userWe also have functions for random float and random integer, and other functions that can return different values depends on some external factors which may vary from time to time, so from my point of view this decision just reduce amount of possible use-cases of this gem
unfortunately with our use-case above this will not return every identifier |
|
That consideration with a custom function makes sense. What if we added a way to mark a function as non-pure that would prevent evaluation in the validation phase? Your example would become something like: Then Dentaku would skip the short circuit eval and return dependencies from both branches of the IF. |
|
Imho |
|
Those are both fair points. Regarding performance: purity is a property of the parsed AST, so it would be computed once per node at construction and memoized — the check at dependency-resolution time is a boolean read per guard, not an extra tree walk. Compared to the existing tokenization/parsing (and the dependency walk itself), the overhead should be negligible, especially with AST caching. On whether the flag is a "hack" — I'd argue the opposite. As the definer of a custom function, you're the only one who knows whether it's pure / side-effect-free; the gem can't infer that from a lambda. A registration-time declaration is how spreadsheets handle this exact problem (e.g. Excel's "volatile" functions — NOW(), RAND()). The only alternative I see is treating all custom functions as opaque in guard positions, which would disable short-circuiting even for users whose functions are pure. And it's worth noting the flag would also fix the double-evaluation in your original benchmark: since the dependency pre-check would skip a non-pure predicate, Separately — for your dependency-listing use case specifically, I think the cleaner answer is a method that returns all identifiers from the AST, regardless of branching. It wouldn't evaluate anything or require you to opt-in to the new flag (although the flag would give you the performance benefit of not double-eval'ing your function). That would give consumers the complete list of possible inputs directly. Would that combination cover your cases? |
Track purity through the AST: every node memoizes pure?, and functions registered with the new volatile: option (add_function/add_functions and FunctionRegistry#register) are never executed during dependency resolution. Branch-pruning nodes (IF, CASE, AND/OR) now prune only when their guards are pure; otherwise they fall back to reporting every branch, so a conditional guarded by a volatile function requires variables from all branches and evaluates the guard exactly once per evaluate! instead of twice. Also add Calculator#identifiers, a purely syntactic listing of every identifier a formula could reference regardless of branching, backed by a reserved __static_dependencies context key that disables pruning. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Yeah this will be nice (that's what I initially thought |
Track purity through the AST: every node memoizes pure?, and functions registered with the new volatile: option (add_function/add_functions and FunctionRegistry#register) are never executed during dependency resolution. Branch-pruning nodes (IF, CASE, AND/OR) now prune only when their guards are pure; otherwise they fall back to reporting every branch, so a conditional guarded by a volatile function requires variables from all branches and evaluates the guard exactly once per evaluate! instead of twice. Also add Calculator#identifiers, a purely syntactic listing of every identifier a formula could reference regardless of branching, backed by a reserved __static_dependencies context key that disables pruning. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Problem:
with current "short-circuit" approach predicate value can be evaluated twice, which can lead to performance issues (in case when predicate is huge and complex), and also can lead to issues in code that depends on
dependenciesarray (#197)for example doing some heavy operation inside of predicate (making DB call, net request or just doing huge amount of calculations)
will result in
or using some randomness
#dependenciescan return different valueswill result in either
or
Proposal
do not evaluate predicate at all and build dependencies for all args of IF each time