Skip to content
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

Support multiple guarded transitions triggered by the same event #72

Merged
merged 16 commits into from
Jun 27, 2024

Conversation

dkumsh
Copy link

@dkumsh dkumsh commented Jun 5, 2024

This PR adds two new features:

  • Support for multiple guarded transitions for a triggering event. If more than one guard succeeds, the first transition will occur.
  • Extension of guard syntax: support for guard expressions rather than guard functions, and change the signature of guard functions.

Originally, only a single guard function was allowed, i.e., expressions like [guard1], where guard1 was a function returning Result<(), Error>. In fact, these are not exactly guards. Guards are normally defined as boolean functions/expressions that enable/disable transitions rather than allow the transitions to fail or not fail.

Therefore, this PR changes the syntax of a guard function's return type to bool and allows multiple transitions for a triggering event, so that it triggers the transition whose guard is enabled. Additionally, the extended syntax allows the use of guard expressions, enabling the combination of different guard functions in these expressions. Example from a unit test:

statemachine! {
    derive_states: [Display, Debug],
    transitions: {
        *Init + Login(&'a Entry) [valid_entry] / attempt = LoggedIn,
        Init + Login(&'a Entry) [!valid_entry && !too_many_attempts] / attempt = Init,
        Init + Login(&'a Entry) [!valid_entry && too_many_attempts] / attempt = LoginDenied,
        LoggedIn + Logout / reset = Init,
    }
}

I hope these changes will add value to this project. Also, as this project was originally inspired by the BoostSML library, I should mention that the proposed changes will make the syntax closer to compatibility with BoostSML.

@dkumsh dkumsh marked this pull request as draft June 5, 2024 11:31
@dkumsh dkumsh changed the title Support multiple guarded transitions from a state triggered by the same event Support multiple guarded transitions triggered by the same event Jun 5, 2024
@dkumsh dkumsh marked this pull request as ready for review June 8, 2024 08:05
@ryan-summers
Copy link
Collaborator

Originally, only a single guard function was allowed, i.e., expressions like [guard1], where guard1 was a function returning Result<(), Error>. In fact, these are not exactly guards. Guards are normally defined as boolean functions/expressions that enable/disable transitions rather than allow the transitions to fail or not fail.

We have opted explicitly to deviate with the return type of guards because other languages (namely C++ that this was based on) had no notion of Result types. From library usage, I can tell you first hand that having guards return Result types is extremely benefitial. Result::is_err() is semantically equivalent to returning false from the guard, but we can maintain more context as to why the guard failed based on the error provided. I don't see the value in reverting back to a boolean. What was the purpose of this change? You can still combine multiple guards that return Result types using Result combinatorials like and_then and or.

Copy link
Collaborator

@ryan-summers ryan-summers left a comment

Choose a reason for hiding this comment

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

Overall, the changes look sound and solid, but I'm confused by the decision to migrate from Result to bool. Is there a reason you've made this choice instead of using Result combinatorics to combine multiple Result types?

.justfile Outdated Show resolved Hide resolved
tests/compile-fail/wildcard_before_input_state.rs Outdated Show resolved Hide resolved
macros/src/codegen.rs Outdated Show resolved Hide resolved
macros/src/codegen.rs Outdated Show resolved Hide resolved
macros/src/codegen.rs Outdated Show resolved Hide resolved
macros/src/codegen.rs Outdated Show resolved Hide resolved
macros/src/codegen.rs Outdated Show resolved Hide resolved
macros/src/codegen.rs Outdated Show resolved Hide resolved
macros/src/codegen.rs Outdated Show resolved Hide resolved
macros/src/codegen.rs Show resolved Hide resolved
@dkumsh
Copy link
Author

dkumsh commented Jun 21, 2024

We have opted explicitly to deviate with the return type of guards because other languages (namely C++ that this was based on) had no notion of Result types. From library usage, I can tell you first hand that having guards return Result types is extremely benefitial. Result::is_err() is semantically equivalent to returning false from the guard, but we can maintain more context as to why the guard failed based on the error provided. I don't see the value in reverting back to a boolean. What was the purpose of this change? You can still combine multiple guards that return Result types using Result combinatorials like and_then and or.

I see your point, and I agree that with only one transition per (state,event) pair enabled, this logic makes sense.

One detail to note is that in the original implementation, Result<(),()> is used. The second unit type () does not provide additional information on what you refer to as a guard failure. In fact, as per SML, the guard is intended to enable or disable a transition. This is indeed somewhat similar to bool (true/false), rather than to an Ok/Error result.

When we switch to multiple transitions, this difference becomes more important. For example, consider the following:

STATE1 + EVENT1 [guard1] ...
STATE1 + EVENT1 [guard2] ...

Say guard1 is disabled and guard2 is enabled. With the original approach, guard1 is disabled meant that guard1 failed, and the state machine immediately returned with an error. However, now, with multiple transitions enabled, we cannot interpret the fact that guard1 is disabled as an error and stop there. We need to check guard2. This is the motivation for the change I propose. There some more reasons, which I think make bool result type more usable.
For example we will allow boolean expressions on top of guards more easily:

Init + Login(&'a Entry) [!valid_entry && !too_many_attempts] / attempt = Init,

Other things to mention: this is more compatible with the SM UML spec as well as with the BoostSML.

I hope this will convince to support this change.

@dkumsh
Copy link
Author

dkumsh commented Jun 24, 2024

added unit tests for guard expressions parser

@dkumsh dkumsh requested a review from ryan-summers June 24, 2024 11:24
@ryan-summers
Copy link
Collaborator

ryan-summers commented Jun 26, 2024

Say guard1 is disabled and guard2 is enabled. With the original approach, guard1 is disabled meant that guard1 failed, and the state machine immediately returned with an error. However, now, with multiple transitions enabled, we cannot interpret the fact that guard1 is disabled as an error and stop there. We need to check guard2. This is the motivation for the change I propose. There some more reasons, which I think make bool result type more usable. For example we will allow boolean expressions on top of guards more easily:

Hmm you make a good point that using Err() to mean "guard did not pass" is a bit of a misnomer, since the guard may not have been able to process successfully. What if we instead updated guards to return Result<bool, Err = ()>? That way, guards could still have fallible operations (I see this as a necessary requirement), but we can return the result of the guard in the Ok() branch. Then, during a transition:

  1. If the guard returned an Err(), propogate that all the way back out as an error in sm.process_event() (i.e. use the ? operator)
  2. Else, use the Ok(passed) result to determine if the guard succeeded

I think that it's important that guards are able to return a result. If you want a functional example of a state machine using fallible guards, take a look at https://github.com/quartiq/minimq/blob/master/src/mqtt_client.rs#L181

There is some complexity with this design about guard execution - what happens if one passes and we don't need to evaluate other guards? What if a guard fails after we've already determined some acceptable condition?

I'd personally say:

  1. Always execute all of the guards
  2. Return any Err() types encountered using ?

@dkumsh
Copy link
Author

dkumsh commented Jun 26, 2024

There is some complexity with this design about guard execution - what happens if one passes and we don't need to evaluate other guards?

The three state approach (enabled/disabled/ failed with an error) to the guards makes sense.
In this case a guard can also work as a validator of an extended state, and throw an error in case it is invalid.

I also thought about this option, and I had prepared the change to switch to Result<bool,_> .
I just pushed it to my PR.
My approach however does "lazy" evaluation, i.e. it evaluates the guard expression as normal rust expression.
For example if a guard expression is defined as : [a||!b] it translates to rust expression self.context.a(...)? || !self.context.b()?.
Therefore, if a() returns Ok(true) then b() will not be evaluated. I would keep this approach as it is intuitively expected behaviour.

@ryan-summers
Copy link
Collaborator

I also thought about this option, and I had prepared the change to switch to Result<bool,_> . I just pushed it to my PR. My approach however does "lazy" evaluation, i.e. it evaluates the guard expression as normal rust expression. For example if a guard expression is defined as : [a||!b] it translates to rust expression self.context.a(...)? || !self.context.b()?. Therefore, if a() returns Ok(true) then b() will not be evaluated. I would keep this approach as it is intuitively expected behaviour.

This approach seems fine to me :) Thanks for the discussion! I'll take a look at all the changes now.

@dkumsh
Copy link
Author

dkumsh commented Jun 26, 2024

1 Always execute all of the guards
2 Return any Err() types encountered using ?

Agree on 2.

On 1:
In case there are multiple guarded transitions for a given state and event, there are basically two approaches:

Evaluate guard expressions for all such transitions, ensuring there are no conflicts (i.e., only one expression returns true). If no conflicts are detected, select the transition.

Evaluate guard expressions in the order they appear in the state machine definition until either a successful transition is found or an error is thrown (errors will be delivered with the ? operator).

The first approach has a slight advantage in that it can detect guard conflicts at runtime. However, its usefulness is debatable. Conflict detection at runtime does not guarantee that the logic is conflict-free; the conflict may still exist but never occur with the given data. On the other hand, this approach comes with a cost, as evaluating guard expressions for all transitions (for a given state and event) might be expensive.

The second approach does not detect conflicts but seems more practical, as it does not carry the overhead. I would vote for the second approach as a reasonable tradeoff.

@dkumsh
Copy link
Author

dkumsh commented Jun 26, 2024

This approach seems fine to me :) Thanks for the discussion! I'll take a look at all the changes now.

Thanks

Copy link
Collaborator

@ryan-summers ryan-summers left a comment

Choose a reason for hiding this comment

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

This looks mostly good, just a few minor nitpicks. It would also be good if we could update doucmentation in the README around this.

CHANGELOG.md Outdated Show resolved Hide resolved
examples/guard_custom_error.rs Show resolved Hide resolved
macros/src/codegen.rs Outdated Show resolved Hide resolved
macros/src/codegen.rs Outdated Show resolved Hide resolved
macros/src/codegen.rs Outdated Show resolved Hide resolved
macros/src/parser/transition.rs Outdated Show resolved Hide resolved
macros/src/validation.rs Show resolved Hide resolved
tests/pass/guarded_transition_before_unguarded.rs Outdated Show resolved Hide resolved
tests/test.rs Outdated Show resolved Hide resolved
tests/test.rs Outdated Show resolved Hide resolved
@ryan-summers
Copy link
Collaborator

You'll also need to run cargo clippy and address any of the warnings and cargo fmt --all to fix formatting before CI will be happy

@dkumsh
Copy link
Author

dkumsh commented Jun 26, 2024

@ryan-summers thanks for the review! I've made the changes accordingly

@ryan-summers
Copy link
Collaborator

Please check the clippy output: https://github.com/korken89/smlang-rs/actions/runs/9682709221/job/26716714317?pr=72 - other than that, the changes look good.

@dkumsh
Copy link
Author

dkumsh commented Jun 26, 2024

oops, sorry , will check clippy

@dkumsh
Copy link
Author

dkumsh commented Jun 26, 2024

@ryan-summers : please see clippy errors fixed.

Copy link
Collaborator

@ryan-summers ryan-summers left a comment

Choose a reason for hiding this comment

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

Thanks for the work on this!

@ryan-summers ryan-summers merged commit 4291514 into korken89:master Jun 27, 2024
7 checks passed
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.

None yet

2 participants