Polystate: Composable Finite State Machines

Polystate’s Core Design Philosophy

  1. Record the state machine’s status at the type level.
  2. Achieve composable state machines through type composition.

Finite State Machines (FSMs) are a powerful programming pattern. When combined with composability and type safety, they become an even more ideal programming paradigm.

The polystate library is designed precisely for this purpose. To achieve this, you need to follow a few simple programming conventions. These conventions are very straightforward, and the benefits they bring are entirely worth it.

Practical Effects of Polystate

  1. Define the program’s overall behavior through compositional declarations. This means we gain the ability to specify the program’s overall behavior at the type level. This significantly improves the correctness of imperative program structures. This programming style also encourages us to redesign the program’s state from the perspective of types and composition, thereby enhancing code composability.
  2. Build complex state machines by composing simple states. For the first time, we can achieve semantic-level code reuse through type composition. In other words, we have found a way to express semantic-level code reuse at the type level. This approach achieves three effects simultaneously: conciseness, correctness, and safety.
  3. Automatically generate state diagrams. Since the program’s overall behavior is determined by declarations, polystate can automatically generate state diagrams. Users can intuitively understand the program’s overall behavior through these diagrams.

I believe all of this represents a great step forward for imperative programming!

6 Likes

reddit

Hacker News

I know you have examples in the github page. May I suggest you point us to an article explaining why one would want to use your library, what it does, etc? Some sort of introductory material might help us (me) understand its place in the continuum.

Thanks!

2 Likes

FSMs are well used in embedded systems and I’m going to make use of this library in some MicroZig projects. Thanks for making something so cool!

2 Likes

Thanks for sharing, I like seeing new approaches to structuring programs. I’ve experimented a bit with similar ideas of representing state machines in types, but never cracked the code of how to do the composability like you did, it’s quite clever.

I must say though, as someone who doesn’t have a background in functional programming or type theory, it took quite a bit of chewing through source code to actually grasp what the different components and ‘conventions’ of your system do. I strongly believe in code readability and minimizing the time it takes for an ignorant third party to understand your code, and I feel like if I saw something like your example ray-game in the wild, I would be frustrated with the amount of extra research required to decipher it.

Now, I don’t know your exact intentions with polystate, if it’s just a proof-of-concept to demonstrate your paradigm then it’s solid as-is, but if you want to develop it into a widely used library I have a few ideas that I think would make it a lot more valuable:

  • The README should include precise definitions for each of polystate’s concepts/conventions, assuming as little prior knowledge as possible. Try to focus not just on what theoretical concept each one represents (like the idea of a ‘witness’), but also their exact behavior and interaction with other concepts in the context of polystate. You already do this somewhat with the comments in the example code, but I still feel like you leave too much to interpretation.
  • The names of your declarations should follow zig casing conventions. This is a simple nitpick, and shouldn’t be too hard to change.
  • In addition to their casing, you also should re-examine the names themselves. I don’t think the names are terrible, but it’s much better to do a big naming refactor before the project sees wider usage. Naming is a divisive thing, but my personal opinion is that any abbreviation in a name should have a concrete justification. What I would do is take an example project like ray-game and test out how different names look aesthetically, starting with the name’s longest non-abbreviated form. You can then see whether they are too common+bulky and need abbreviation, or if they look alright with a longer name.
2 Likes

This is just a finite state machine (FSM) library. You can use this library anywhere you can use FSM. It’s just that this state machine works on types and supports compositionality, which makes it very difficult for developers who are not familiar with these concepts to understand. Of course, the benefits of doing so are what I mentioned above. I will try to write more articles to help you better understand this library.

1 Like

Thank you for using this library!

Thank you for your compliment!

Writing this document was more difficult than I anticipated. Although I have been writing and using this library for a while, it’s not easy to explain it clearly to others. If you have any questions after reading this document, please don’t hesitate to ask. I’d be happy to clear up any confusion!

As I said in the documentation, I need more feedback to improve the documentation. If I accidentally overlooked something that is not worth mentioning to beginners, please let me know.

The actual polystate-examples are examples suitable for beginners. From simple to difficult, they are: cont = counter < atm < todo

ray-game is an experimental library. I used it to explore the upper limit of polystate. It shows the powerful effect of polystate, but it is not suitable for beginners. There are actually corresponding video explanations, but they are all in Chinese, so I didn’t add a link.

My goal is of course to become a widely used library.

  1. I will write more articles explaining this library but I need some feedback.
  2. I’m ashamed to say that I don’t know the naming conventions for zigs. I’ll look for relevant information and follow those conventions.
  3. As you know, my native language is not English, and my English is very poor. We communicate through a translator. So all the names you see are very arbitrary. . . . I welcome everyone to contribute code and modify these irregular names. My only requirement is that the function be named sdzx, and when I first wrote it I decided to name it after myself.

Finally thanks for your suggestions, I’m really glad that someone can understand and use this library.

2 Likes

I was studying the use of polystate through ray-game, and I just got a good idea: returns the first stage from the second stage of a two-stage select. code

【polystate 19: 通过符号推理构建程序,划时代的构建程序的方式!】 polystate 19: 通过符号推理构建程序,划时代的构建程序的方式!_哔哩哔哩_bilibili

Constructing programs through symbolic reasoning, a revolutionary way of constructing programs!

Designing programs through symbolic reasoning may be the correct way to use polystate. A new era seems to be beckoning to me!

I’ve been thinking about different ways that this library could be structured to be more accessible, and I came up with a stripped-down variant of polystate that’s much simpler, but still covers a lot polystate’s functionality. I wanted to hear your thoughts on it, as I think you might be able to simplify polystate in a similar way.

The main benefits of my approach:

  • The representation of a state is more concrete. A state is just a struct with no fields, and the state’s transition logic is in the struct’s declarations.
  • Composition is as simple as passing states into a function that returns another state.
  • State names can be trivially refactored with ZLS, as a state’s name never needs to be declared in more than one place.

The main conventions I follow in this example:

  • Each state declares the states it can transition to.
  • Each state declares its name.
  • Each state declares a transition function, which returns either the next transition function or an exit signal.
const std = @import("std");

pub fn main() !void {
    const StartingState = Example.AOrB(Example.State1, Example.State2);

    var gst: GlobalState = .init;

    sw: switch (StartingState.transition(&gst)) {
        .Current => |function| {
            continue :sw function(&gst);
        },
        .Exit => {},
    }
}

pub const GlobalState = struct {
    counter: u64,
    prng: std.Random.DefaultPrng,

    pub const init: GlobalState = .{
        .counter = 0,
        .prng = .init(123),
    };
};

pub const Example = struct {
    pub const Exit = struct {
        pub const name = getName(Example, Exit);

        pub fn transition(_: *GlobalState) TransitionResult {
            return .{ .Exit = {} };
        }
    };

    pub const State1 = struct {
        pub const transitions = .{
            Exit,
            AOrB(State1, State2),
        };
        pub const name = getName(Example, State1);

        pub fn transition(gst: *GlobalState) TransitionResult {
            return genericTransition(State1, gst);
        }

        pub fn transitionInt(gst: *GlobalState) usize {
            gst.counter += 1;
            if (gst.counter >= 10) {
                return intFromTransition(State1, Exit);
            }

            return intFromTransition(State1, AOrB(State1, State2));
        }
    };
    
    pub const State2 = struct {
        pub const transitions = .{
            Exit,
            AOrB(State1, State2),
        };
        pub const name = getName(Example, State2);

        pub fn transition(gst: *GlobalState) TransitionResult {
            return genericTransition(State2, gst);
        }

        pub fn transitionInt(gst: *GlobalState) usize {
            gst.counter += 1;
            if (gst.counter >= 10) {
                return intFromTransition(State2, Exit);
            }

            return intFromTransition(State2, AOrB(State1, State2));
        }
    };

    pub fn AOrB(comptime A: type, comptime B: type) type {
        return struct {
            pub const transitions = .{
                A,
                B,
                AOrB(A, B),
            };
            pub const name = getComposedName(Example, AOrB, .{ A, B });

            const Self = @This();

            pub fn transition(gst: *GlobalState) TransitionResult {
                return genericTransition(Self, gst);
            }

            pub fn transitionInt(gst: *GlobalState) usize {
                const random = gst.prng.random();

                if (random.intRangeLessThan(usize, 0, 3) != 0) {
                    return intFromTransition(Self, AOrB(A, B));
                }

                if (random.boolean()) {
                    return intFromTransition(Self, A);
                }

                return intFromTransition(Self, B);
            }
        };
    }
};

// Utilities:

pub fn getName(comptime Container: type, comptime decl: anytype) []const u8 {
    comptime {
        for (std.meta.declarations(Container)) |possible_decl| {
            const name = possible_decl.name;
            const decl_value = @field(Container, name);

            if (@TypeOf(decl_value) == @TypeOf(decl) and decl_value == decl) {
                return name;
            }
        } else unreachable;
    }
}

pub fn getComposedName(comptime Container: type, comptime decl: anytype, comptime args: anytype) []const u8 {
    comptime {
        var composed_name: []const u8 = "";

        composed_name = composed_name ++ getName(Container, decl) ++ "(";

        for (&args, 0..) |arg, i| {
            if (i > 0) {
                composed_name = composed_name ++ ", ";
            }
            composed_name = composed_name ++ arg.name;
        }

        composed_name = composed_name ++ ")";

        return composed_name;
    }
}

pub fn genericTransition(comptime State: type, gst: *GlobalState) TransitionResult {
    switch (State.transitionInt(gst)) {
        inline 0...State.transitions.len - 1 => |int| {
            std.debug.print("{s} -> {s}\n", .{ State.name, TransitionFromInt(State, int).name });
            return .{ .Current = TransitionFromInt(State, int).transition };
        },
        else => unreachable,
    }
}

pub fn intFromTransition(comptime State: type, comptime TransitionState: type) usize {
    return comptime std.mem.indexOfScalar(type, &State.transitions, TransitionState) orelse unreachable;
}

pub fn TransitionFromInt(comptime State: type, comptime int: usize) type {
    return State.transitions[int];
}

pub const TransitionResult = union(enum) {
    Exit: void,
    Current: *const fn (gst: *GlobalState) TransitionResult,
};

1 Like

I need some time to analyze it in detail.

It took a lot of effort to make the video today, and it may take me a long time to understand your code.

I think it is fairly easy to roll your own state machine in zig. As an example, here is a quick and dirty implementation of a state machine with composition and type safety - based on the approach used by ELM.

const Counter = struct {
    counter: i32,

    pub const init = Counter{
        .counter = 0,
    };

    const Event = enum {
        Inc,
        Dec,
    };

    pub fn update(self: *Counter, event: Event) void {
        switch (event) {
            .Inc => self.counter += 1,
            .Dec => self.counter -= 1,
        }
    }
};

const DoubleCounter = struct {
    a: Counter,
    b: Counter,

    pub const init = DoubleCounter{
        .a = Counter.init,
        .b = Counter.init,
    };

    const Event = enum {
        IncA,
        DecA,
        IncB,
        DecB,
    };

    pub fn update(self: *DoubleCounter, event: Event) void {
        switch (event) {
            .IncA => self.a.update(.Inc),
            .DecA => self.a.update(.Dec),
            .IncB => self.b.update(.Inc),
            .DecB => self.b.update(.Dec),
        }
    }
};

pub fn main() !void {
    var st = DoubleCounter.init;
    std.debug.print("{any}\n", .{st});
    st.update(.IncA);
    std.debug.print("{any}\n", .{st});
    st.update(.DecB);
    std.debug.print("{any}\n", .{st});
}

const std = @import("std");

What does your library brings that is not covered here?

1 Like

There is a full discussion of compositionality here Polystate: Composable Finite State Machines | Hacker News

For your extremely simple demo, there really is no advantage to using polystate.

But you can take a look at my documentation and demos, there are sufficiently complex examples there.

The human brain is very limited in the number of states it can handle, and type safety and composability allow me to easily build very complex state machines at a very high level.

I was experimenting a little more, and found out that it’s relatively easy to make a static version of the entire state machine under my model (no function pointers required). Here’s a comparison of the function pointer vs static techniques, you will see that a lot less work is done in the static version: https://godbolt.org/z/q8K9ehrdK.

Here’s the code, the comptime logic is a little inefficient (at least O(n^2) for n possible states), but I didn’t want to bother with making a comptime map:

pub fn staticStateMachine(comptime StartingState: type, comptime EndingState: type) fn (gst: *GlobalState) void {
    comptime {
        const reachable_states = getReachableStates(StartingState);

        const ending_state_int = std.mem.indexOfScalar(type, reachable_states, EndingState) orelse unreachable;

        const S = struct {
            pub fn traverse(gst: *GlobalState) void {
                const starting_state_int = comptime std.mem.indexOfScalar(type, reachable_states, StartingState) orelse unreachable;
                sw: switch (starting_state_int) {
                    inline 0...reachable_states.len - 1 => |current_state_int| {
                        if (current_state_int == ending_state_int) {
                            return;
                        }

                        const CurrentState = reachable_states[current_state_int];

                        switch (CurrentState.transitionInt(gst)) {
                            inline 0...CurrentState.transitions.len - 1 => |int| {
                                const NextState = TransitionFromInt(CurrentState, int);
                                std.debug.print("{s} -> {s}\n", .{ CurrentState.name, NextState.name });
                                continue :sw comptime std.mem.indexOfScalar(type, reachable_states, NextState) orelse unreachable;
                            },
                            else => unreachable,
                        }
                    },
                    else => unreachable,
                }
            }
        };

        return S.traverse;
    }
}

pub fn getReachableStates(comptime StartingState: type) []const type {
    comptime {
        var reachable_states: []const type = &.{};

        return getReachableStatesRecursive(StartingState, &reachable_states);
    }
}

fn getReachableStatesRecursive(comptime CurrentState: type, comptime reachable_states: *[]const type) []const type {
    comptime {
        for (reachable_states.*) |VisitedState| {
            if (VisitedState == CurrentState) {
                return reachable_states.*;
            }
        }

        reachable_states.* = reachable_states.* ++ [_]type{CurrentState};

        if (@hasDecl(CurrentState, "transitions")) {
            const transitions = CurrentState.transitions;

            for (transitions) |TransitionState| {
                reachable_states.* = getReachableStatesRecursive(TransitionState, reachable_states);
            }
        }

        return reachable_states.*;
    }
}
2 Likes

Your implementation should have the same capabilities as polystate! Very cool.

For example, you can modify State2 to look like this, and the nested AOrB seems to work fine.

    pub const State2 = struct {
        pub const transitions = .{
            Exit,
            AOrB(AOrB(State1, State2), State2),
        };
        pub const name = getName(Example, State2);

        pub fn transition(gst: *GlobalState) TransitionResult {
            return genericTransition(State2, gst);
        }

        pub fn transitionInt(gst: *GlobalState) usize {
            gst.counter += 1;
            if (gst.counter >= 10) {
                return intFromTransition(State2, Exit);
            }

            return intFromTransition(State2, AOrB(AOrB(State1, State2), State2));
        }
    };

You may have noticed that AOrB(AOrB(State1, State2), State2) is written twice. Using a tagged union is more convenient and also allows the message to carry parameters. Of course this is not decisive.

The key points are as follows:

Yesterday, through some symbolic reasoning, I realized that a more expressive general state machine would require that fun here can also be treated as a parameter.

Then, analogous to your code, there will be code similar to the following, which has no meaning, just showing this situation:

    pub fn AOrB(comptime A: type, comptime B: type, comptime F: fn (type, type) type) type {
        return struct {
            pub const transitions = .{
                A,
                B,
                F(A, B),
            };
 

This seems very reasonable!

Would you be willing to make a more complete code? I’ll use it in ray-game and then compare it to the existing code.

My understanding here is to fully expand the state machine into a label switch. Is my understanding correct?

This is so cool!!!

I formally invite you to join me in developing polystate! ! ! !

You are more experienced in implementing state machines than I am, I am better at types and compositional semantics.

We can kill transition !!!!!!!

The simplest example:

const std = @import("std");

pub fn main() !void {
    const StartingState = Witness(Example.Exit, Example.State1);

    var gst: GlobalState = .init;

    sw: switch (StartingState.transition(&gst)) {
        .Current => |function| {
            continue :sw function(&gst);
        },
        .Exit => {},
    }
}

pub const GlobalState = struct {
    counter: u64,

    pub const init: GlobalState = .{
        .counter = 0,
    };
};

pub fn Witness(End: type, Current: type) type {
    if (Current == End) {
        return struct {
            pub const name = Current.name;
            pub fn transition(gst: *GlobalState) TransitionResult {
                _ = gst;
                std.debug.print("end: {s} \n", .{Current.name});
                return .Exit;
            }
        };
    } else {
        return struct {
            pub const name = Current.name;
            pub fn transition(gst: *GlobalState) TransitionResult {
                switch (Current.transitionInt(gst)) {
                    inline else => |wit, tag| {
                        _ = tag;
                        std.debug.print("{s} -> {s}\n", .{ Current.name, @TypeOf(wit).name });
                        return .{ .Current = @TypeOf(wit).transition };
                    },
                }
            }
        };
    }
}

pub const Example = struct {
    pub const Exit = struct {
        pub const name = getName(Example, Exit);
    };

    pub const State1 = struct {
        pub const transitions = union(enum) {
            exit: Witness(Exit, Exit),
            state1: Witness(Exit, State1),
        };

        pub const name = getName(Example, State1);

        pub fn transitionInt(gst: *GlobalState) transitions {
            gst.counter += 1;
            if (gst.counter >= 10) {
                return .exit;
            }

            return .state1;
        }
    };
};

// Utilities:

pub fn getName(comptime Container: type, comptime decl: anytype) []const u8 {
    comptime {
        for (std.meta.declarations(Container)) |possible_decl| {
            const name = possible_decl.name;
            const decl_value = @field(Container, name);

            if (@TypeOf(decl_value) == @TypeOf(decl) and decl_value == decl) {
                return name;
            }
        } else unreachable;
    }
}

pub const TransitionResult = union(enum) {
    Exit: void,
    Current: *const fn (gst: *GlobalState) TransitionResult,
};

In order to handle the Exit state correctly, Witness requires two parameters. This is back to my original design! ! ! ! !