korken89 / smlang-rs

A State Machine Language DSL procedual macro for Rust
Apache License 2.0
193 stars 28 forks source link

Support multiple guarded transitions triggered by the same event #72

Closed dkumsh closed 1 month ago

dkumsh commented 2 months ago

This PR adds two new features:

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.

ryan-summers commented 1 month ago

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.

dkumsh commented 1 month ago

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 commented 1 month ago

added unit tests for guard expressions parser

ryan-summers commented 1 month ago

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 commented 1 month ago

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 commented 1 month ago

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 commented 1 month ago

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 commented 1 month ago

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

Thanks

ryan-summers commented 1 month ago

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 commented 1 month ago

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

ryan-summers commented 1 month ago

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 commented 1 month ago

oops, sorry , will check clippy

dkumsh commented 1 month ago

@ryan-summers : please see clippy errors fixed.