Skip to content

[WIP] first prototype of default value rules implemented #749

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 5 commits into
base: master
Choose a base branch
from

Conversation

Bumblebee00
Copy link

@Bumblebee00 Bumblebee00 commented Jun 3, 2025

what are default value rules

Reading Mathematica pattern matching documantation (here) I saw they have a pattern matching with slots that can take a default value. In Mathematica a generic pattern named "a" is written a_ (in julia symbolics ~a ). But then if I write a_. it matches a pattern with built in default value, so basically (a_. + b_*x_) can match 2x where "a" takes the default value of 0 (and b=2 of course). or another example (a_ + b_*x_)^m_. can match (1 + 2x) where m takes the default value of 1 (and a=1 b=2 of course), whereas the pattern (a_ + b_*x_)^m_ would not match (1 + 2x) because the exponent m needs to be there and cannot take a default value.

why i do them

This feature is needed for my GSoC project of implementing a rule based integrator in julia symbolics.

what i did

Now I implemented this default value for sum and multiplication.
I choose the syntax of this new slot to be ~!x (but i could choose any function that is one charachter and has one argument, for example also √), so for example:

r_mult = @rule (~x * ~!y + ~z) => ~y
r_mult(c + a*b) # returns b
r_mult(c + b) # returns 1

whereas

r_mult_old = @rule (~x * ~y + ~z) => ~y
r_mult_old(c + a*b) # returns b
r_mult_old(c + b) # returns nothing because it did not match

Note that you don't need to put the exclamation point also after the =>. I don't kow if this is ok or needs to be changed.

how i did it

i created a new type in rules.jl called DefSlot, alongside the already present Slot (used for ~x) and Segment (used for ~~x). It's the same as Slot but it has a field for the default value.

At the creation of the rule with the @rulemacro these things happen:

The makepattern function creates a tree of slots, segments and defslots starting from the expression of the left hand side of the rule.

a matcher is created, starting by calling the function
https://github.com/Bumblebee00/SymbolicUtils.jl/blob/a95aea2310ea12f1ee2d9b2850d062ec82a437c4/src/matchers.jl#L8

that calls a bunch of other functions returning a tree of stot matchers (for slots), segment matchers (for segments), literal matchers (for numbers and others), term matchers (for combination of the previous), and i added term_matcher_defslot that matches either a term (if the rule has all the pieces) or a single symbol (if the default value needs to be used)
https://github.com/Bumblebee00/SymbolicUtils.jl/blob/66026d6f2e7b50250d05625b111836d9d9dd1842/src/matchers.jl#L145-L176

Copy link
Contributor

github-actions bot commented Jun 3, 2025

Benchmark Results

master 4e04a9b... master / 4e04a9b...
overhead/acrule/a+2 1.2 ± 0.33 μs 0.968 ± 0.31 μs 1.24 ± 0.52
overhead/acrule/a+2+b 1.2 ± 0.32 μs 1.01 ± 0.32 μs 1.18 ± 0.49
overhead/acrule/a+b 0.276 ± 0.037 μs 0.262 ± 0.014 μs 1.05 ± 0.15
overhead/acrule/noop:Int 1.56 ± 0.01 ns 1.56 ± 0.01 ns 1 ± 0.009
overhead/acrule/noop:Sym 27.9 ± 5.9 ns 27.4 ± 6 ns 1.02 ± 0.31
overhead/rule/noop:Int 0.0354 ± 0.026 μs 0.0357 ± 0.024 μs 0.994 ± 0.99
overhead/rule/noop:Sym 0.0337 ± 0.025 μs 0.0397 ± 0.024 μs 0.849 ± 0.81
overhead/rule/noop:Term 0.0342 ± 0.025 μs 0.0399 ± 0.024 μs 0.855 ± 0.81
overhead/ruleset/noop:Int 0.123 ± 0.0038 μs 0.12 ± 0.0041 μs 1.02 ± 0.047
overhead/ruleset/noop:Sym 0.12 ± 0.0028 μs 0.128 ± 0.0048 μs 0.937 ± 0.041
overhead/ruleset/noop:Term 4.52 ± 0.14 μs 4.55 ± 0.12 μs 0.994 ± 0.041
overhead/simplify/noop:Int 0.21 ± 0.014 μs 0.214 ± 0.014 μs 0.982 ± 0.091
overhead/simplify/noop:Sym 0.21 ± 0.014 μs 0.209 ± 0.015 μs 1 ± 0.098
overhead/simplify/noop:Term 0.0484 ± 0.0016 ms 0.0493 ± 0.0021 ms 0.983 ± 0.052
overhead/simplify/randterm (+, *):serial 0.12 ± 0.0038 s 0.121 ± 0.0032 s 0.99 ± 0.041
overhead/simplify/randterm (+, *):thread 0.0707 ± 0.0062 s 0.0703 ± 0.0071 s 1 ± 0.13
overhead/simplify/randterm (/, *):serial 0.235 ± 0.023 ms 0.236 ± 0.023 ms 0.995 ± 0.14
overhead/simplify/randterm (/, *):thread 0.268 ± 0.021 ms 0.27 ± 0.02 ms 0.993 ± 0.11
overhead/substitute/a 0.107 ± 0.012 ms 0.104 ± 0.013 ms 1.04 ± 0.17
overhead/substitute/a,b 0.0921 ± 0.01 ms 0.0901 ± 0.011 ms 1.02 ± 0.17
overhead/substitute/a,b,c 21.5 ± 2.7 μs 21.5 ± 2.6 μs 0.997 ± 0.17
polyform/easy_iszero 0.0413 ± 0.0014 ms 0.0411 ± 0.0014 ms 1.01 ± 0.049
polyform/isone 2.79 ± 0.01 ns 2.79 ± 0 ns 1 ± 0.0036
polyform/iszero 1.43 ± 0.032 ms 1.43 ± 0.031 ms 1 ± 0.031
polyform/simplify_fractions 2.05 ± 0.1 ms 1.95 ± 0.035 ms 1.05 ± 0.056
time_to_load 1.15 ± 0.011 s 1.13 ± 0.011 s 1.01 ± 0.014

Benchmark Plots

A plot of the benchmark results have been uploaded as an artifact to the workflow run for this PR.
Go to "Actions"->"Benchmark a pull request"->[the most recent run]->"Artifacts" (at the bottom).

@AayushSabharwal
Copy link
Member

Nice! I'm a little concerned about ordering here. How does this handle commutative operations?

r_mult = @rule (~x * ~!y + ~z) => ~y
r_mult(c*d + a*b) # returns?

This could reasonably return any one of a, b, c, or d.

r_mult(2c + b) # returns?

This could return 1 or 2.

How are these cases handled?

@Bumblebee00
Copy link
Author

@AayushSabharwal the symbols are reorderd in a kind of alphabetical order before being passed to the rule, we can also check it from julia repl:

julia> c*d + a*b
a*b + c*d

julia> 2c + b
b + 2c

being the rule macro not commutiative, is returned the vaule that matches the rule in the ordered expression, so:

julia> r_mult(c*d + a*b) # is like r_mult(a*b + c*d)
b

julia> r_mult(2c + b) # is like r_mult(b + 2c)
1

or, another example:

julia> r_mult1 = @rule (~z + ~!y * ~x) => ~y
~z + ~(!y) * ~x => ~y

julia> r_mult2 = @rule (~z + ~x * ~!y) => ~y
~z + ~x * ~(!y) => ~y

julia> r_mult1(1+a*b)
a

julia> r_mult2(1+a*b)
b

julia> r_mult1(1+a)
1

julia> r_mult2(1+a)
1

I didn't think about this, but maybe for the symbolic integrator I need acrules... I dont know

@AayushSabharwal
Copy link
Member

the symbols are reorderd in a kind of alphabetical order before being passed to the rule, we can also check it from julia repl

The printout in the REPL can be misleading. It uses sorted_arguments to print, which in general (especially for addition/multiplication) is not the same as arguments. The order is also not purely lexicographic, it uses a heuristic based on the degree of each term.

I didn't think about this, but maybe for the symbolic integrator I need acrules

ACRules are likely necessary for some integration rules, but it doesn't help with the ambiguity. That said, I'm not sure if the ambiguity actually matters for integration. It seems all our existing matchers use arguments and rely on whatever ordering comes out, so I guess the ambiguity is fine from a relative robustness standpoint.

@Bumblebee00
Copy link
Author

Bumblebee00 commented Jun 4, 2025

The printout in the REPL can be misleading

Yes you are right, in fact after writing the message I found some cases I could not explain:

julia> r_mult1(1+c*d) # I expect c but
d

julia> r_mult2(1+c*d) # I expect d but
c

and in fact:

julia> sorted_arguments(c*d)
2-element Vector{Any}:
 c
 d

julia> arguments(c*d)
2-element SymbolicUtils.SmallVec{Any, Vector{Any}}:
 d
 c

@Bumblebee00
Copy link
Author

Bumblebee00 commented Jun 4, 2025

ok now an operation whith default value can match also with trees (others expressions) like:

    r_mult3 = @rule (~!x)*(~y + ~z) => ~x
    r_pow2 = @rule (~x + ~y)^(~!m) => ~m

previously was only with single symbols like

    r_pow = @rule (~x)^(~!m) => ~m
    r_mult = @rule ~x * ~!y  => ~y
    r_mix = @rule (~x + (~y)*(~!c))^(~!m) => ~m + ~c

atterntion!

A thing that I dont like is: in matchers.jl in the defslot_term_matcher function, that matches a term with default operation, i needed to check whether the operation of the data (on which the rule gets called) is the same as the operation saved in the default value.
I don't know why but I couldn't check with !=, because the operation saved in the default value was of type Symbol, while the one obtained from data was of type typeof(^) (in the example of doing the power rule).
The workaround I found was converting them to strings, so for now i have used:

string(defslot.operation) != string(operation(car(data)))

The operation is saved in the default value in rules.jl in the makepatter function doing operation(expr) where expr is the left hand side of the rule, while the opeeration is retrived from data doing operation(car(data)) in the makepatter function

I'd like to find a way without using strings

src/matchers.jl Outdated
# if data is not a list, return nothing
!islist(data) && return nothing
# if data (is not a tree and is just a symbol) or (is a tree not starting with the default operation)
if !iscall(car(data)) || (istree(car(data)) && string(defslot.operation) != string(operation(car(data))))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does this use string comparison?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, #749 (comment) didn't load for me until I refreshed the page. That behavior is... weird. However, you should be able to match nameof(operation(...)) with defslot.operation.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also FYI the value in DefSlot is a Symbol because it comes from parsing an Expr

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks I didn't know of nameof, I will use it now

src/rule.jl Outdated
return 1
end
# else no default value for this call
return nothing
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why return nothing instead of erroring?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I put it there at the beginning and forgot, is better to trow an error. I will add it now

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants