Tag Archives: Julia

Semantic Versioning (Semver) is flawed, and Downgrade CI is required to fix it

By: Christopher Rackauckas

Re-posted from: http://www.stochasticlifestyle.com/semantic-versioning-semver-is-flawed-and-downgrade-ci-is-required-to-fix-it/

Semantic versioning is great. If you don’t know what it is, it’s just a versioning scheme for software that goes MAJOR.MINOR.PATCH, where

  1. MAJOR version when you make incompatible API changes
  2. MINOR version when you add functionality in a backward compatible manner
  3. PATCH version when you make backward compatible bug fixes

That’s all it is, but it’s a pretty good system. If you see someone has updated their package from v3.2.0 to v3.2.1, then you know that you can just take that update because it’s just a patch, it won’t break your code. You can easily accept patch updates. Meanwhile, if you see they released v3.3.0, then you know that some new features were added, but it’s safe for you to update. This allows you to be compatible with v3.3.0 so that if a different package requires it, great you can both use it! Thus a lot of version updates to your dependencies can be accepted without even thinking about it. However, when you see that v4.0.0, you know that your dependency broke some APIs, so you need to do that compatibility bump automatically. Thus the semvar system makes it much easier to maintain large organizations of packages since the number of manual version bumps that you need to do are rather small.

Because of how useful this can be, many package managers have incorporated a form of semantic versioning into its system. Julia’s package manager, Rust’s package manager, Node’s package manager, and more all have ways that integrate semantic versioning into its systems, making it easy to automatically accept dependency updates and thus keeper a wider set of compatibility than can effectively done manually. It’s a vital part of our current dependency system.

Okay if it’s great, then how can it be flawed?

Semver is flawed for two reasons:

  1. The definition of “breaking” is vague and ill-defined at its edges
  2. Current tooling does not accurately check for Semver compatibility

Breaking: a great concept but with unclear boundaries

The first point is somewhat known and is best characterized by the classic XKCD comic:

Any change can break code. It’s really up to the definition of “what is breaking”. There’s many nuances:

  • “Breaking” only applies to the “public facing API”, i.e. things that users interact with. If anything changing was considered breaking then every change would be a breaking change, so in order for semver to work you have to have some sense of what is considered internals and what is considered public. Julia in its next recent version has a new “public” keyword to declare certain internals as public, i.e. things which are exported and specifically chosen values in a package module are considered internal by default. If you have many users, you will still find someone say “but I use function __xxxxyyyz_internal because the API doesn’t allow me to pass mycacheunsafemathbeware optimally”, but at least you can blame them for it. This is the most solvable issue of semver and simply requires due diligence and sticking to a clear system for what’s exposed and what’s not. That’s a bit harder in dynamic languages, but as shown there are systems in place for this.
  • What is considered “breaking” in terms of functionality can have some fuzzy edges. I work on numerical solvers and connections to machine learning (scientific machine learning or SciML). If someone calls the ODE solver with abstol=1e-6 and reltol=1e-3, then what is returned is an approximation to the ODE’s solution with a few digits of accuracy (there’s some details in here I will ignore). If a change is made internally to the package, say more SIMD for better performance, which causes the result to change in the 12 digit, is that breaking? Because the ODE solver only is guaranteeing at most 3-6 digits of accuracy, probably not. But what if the 6th digit changes? The 5th? If the built-in sin function in the language changes in the 15th digit of accuracy, is that breaking? Most documentation strings don’t explicitly say “this is computed to 1ulp (units in the last place)”, so it’s not always clear what one is truly guaranteed from a numerical function. If someone improves the performance of a random number generator and now the random numbers for the same seed are different, is this breaking? Were you guaranteed that wasn’t going to change? People will argue all day about some of these edge cases, “it broke my tests because I set the random number seed and now it broke”. Look at any documentation, for example numpy.random.rand, and it won’t clarify these details on you can rely on to change and not change. This granularity is a discussion with a vague boundary.
  • One man’s bug fix is another man’s breaking change. You may have intended for all instantiations of f(x::T) (or T.f(x)) to return an integer, but one of them returned a float. So what do you do? You go fix it, make them all return floats, and add documentation on the interface that they all return a floating point value and implement some interface to enforce it across all functions… and then the issues roll in “you broke my code because I required that this version of the function returned an integer!”. A bugfix is by definition correcting an unintended behavior. However, someone has to define “unintended”, and your users may not be able to read your brain and may consider what was “intended” differently. I’m not sure there really is a solution to this because a bug is by definition unintended: if you knew it was there then you would have either fixed it or documented it earlier. But left with no documentation on what to do, the user may thing the behavior is intentional and use it.
  • Adding new functionality may have unintended consequences. You may have previously threw an error in a given case, but now return an approximation. The user may only want exact solutions to some math function f(x), so they relied on the error throw before in order to know if the solution would have been exactly calculable. Your new approximation functionality that you just released with a nice blog post thus just broke somebody’s code. So is it a major update, or a minor update? You never “intended” for only giving exact solutions, the error message might’ve even said “we intend to add this case in the near future with an approximation”, but you still broke their code.

As Churchill said, “democracy is the worst form of government, except for all the others”. In this case, semver is great because it conveys useful information, but we shouldn’t get ahead of ourselves and thus assume it does everything perfectly. Its definitions can be vague and it requires discussion to figure out whether something is breaking or a patch sometimes.

But if it does fail, hopefully our tooling can help us know. We all have continuous integration and continuous deployment (CI/CD), that helps us handle semver… right?

Standard CI/CD Systems are Insufficient to Check Semver Compatibility

I’m no chump so I set my versioning to use semantic versioning. My Project.tomls are all setup to put lower bounds, for example I list out all of my version requirements like a champ (if you’re not familiar with Julia’s package manager, everything defaults to semver and thus DiffEqBase = “6.41” in the compat implicitly means any 6.x with x>41, but 7 is not allowed). We laugh in the face of the Python PyPI system because our package registration system rejects any package (new or new version) which does not have an upper bound. Every package is required to have compatibilities specified, and thus random breakage is greatly reduced. We have forced all package authors to “do the right thing” and users have ultimately one. Package it up, we’re done here.

But then… users… see some breakage? They make a post where they show you that user your package failed. How could that happen? Well it goes back to part one that there are some edges in semantic versioning that may have creeped in somewhere. But many times what has happened is that the authors have simply forgotten what their lower bound means. v3.3.0 introduced the function f(x) in PkgA so when you started to use that function from the dependency, you set the lower bound there and life is good. g(x) was introduced in v3.4.0 and a few years later PkgA is at 3.11.2 you learn about it and go “cool PkgA is great!”, you start using g(x), your CI system says everything is fine, and then a user pops up and says your package is broken for them. When digging into the logs, you see that there’s some other package that only allows The Missing Link: Downgrade CI

The real core issue here is that semantic versioning is generally inadequately tested. In theory, if I put a lower bound saying I accept v3.3.0 and anything above it until v4, then I might be saying I am allowing more than 100 versions of PkgA. If I also have a PkgB with similar semantic versioning, I could be allowing 100 variations of that as well. However, the way everyone’s CI/CD system runs is to take the latest version of the packages. Okay, maybe for some major dependency like the standard library you list ‘Programming Language :: Python :: 3.8’, ‘Programming Language :: Python :: 3.9’, … to test multiple versions of that, but are you checking the 100,000 permutations of all allowed dependency versions? Almost certainly not. Not only have I not seen this, it’s also just infeasible to do in practice.

But as a shortcut, what you should be doing is at least checking the most edgy of edge cases. If I state v3.3.0 is my allowed lower bound, most CI systems will simply grab the latest v3.y.z with y and z as big as possible. However, I should at least have one run with v3.3.0 to see if it’s still sensible. This would have caught that g(x) was not defined in v3.4.0. While this wouldn’t fix all issues with semantic versioning, it can at least identify many of them pretty straightforwardly.

We call this scheme “Downgrade CI”, i.e. downgrade all dependencies to their minimum versions and run it. Most users will only ever see the maximum versions so sure it doesn’t matter to most people, but as people add more and more into their environment they will start to see earlier versions, and it’s these minimum versions that are the true key to whether your package will give a sensible environment, not the version maximums which semver puts so much effort into!

Setting up Downgrade CI

Okay, so hopefully I’ve convinced you that semver is not a magical solution to all compatibility problems, it’s a nice tool but not a silver bullet, and you need to have some form of downgrade CI. How do you actually accomplish this? Thankfully the Julia ecosystem has a julia-downgrade-compat-action which sets up Github Actions CI to automatically run the package versions with this downgrade idea in mind. If you’re scared of trying to figure that out, don’t worry and just copy-paste a script out of SciML. For example, from SciMLBase.jl:

name: Downgrade
on:
  pull_request:
    branches:
      - master
    paths-ignore:
      - 'docs/**'
  push:
    branches:
      - master
    paths-ignore:
      - 'docs/**'
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        version: ['1']
    steps:
      - uses: actions/checkout@v4
      - uses: julia-actions/setup-julia@v1
        with:
          version: ${{ matrix.version }}
      - uses: cjdoris/julia-downgrade-compat-action@v1
        with:
          skip: Pkg,TOML
      - uses: julia-actions/julia-buildpkg@v1
      - uses: julia-actions/julia-runtest@v1

This will add a new set of CI tests which run in the downgraded form and ensure your lower bounds are up to date. Will this solve all version compatibility issues? No, but hopefully this catches most of the major classes of issues.

Conclusion

In conclusion, use downgrade CI because semver isn’t perfect and while it does give a decent idea as to handling of upper bounds, lower bounds still need to be handled quite manually and “manual” is synonym for “can break”.

The post Semantic Versioning (Semver) is flawed, and Downgrade CI is required to fix it appeared first on Stochastic Lifestyle.

Semantic Versioning (Semver) is flawed, and Downgrade CI is required to fix it

By: Christopher Rackauckas

Re-posted from: http://www.stochasticlifestyle.com/semantic-versioning-semver-is-flawed-and-downgrade-ci-is-required-to-fix-it/

Semantic versioning is great. If you don’t know what it is, it’s just a versioning scheme for software that goes MAJOR.MINOR.PATCH, where

  1. MAJOR version when you make incompatible API changes
  2. MINOR version when you add functionality in a backward compatible manner
  3. PATCH version when you make backward compatible bug fixes

That’s all it is, but it’s a pretty good system. If you see someone has updated their package from v3.2.0 to v3.2.1, then you know that you can just take that update because it’s just a patch, it won’t break your code. You can easily accept patch updates. Meanwhile, if you see they released v3.3.0, then you know that some new features were added, but it’s safe for you to update. This allows you to be compatible with v3.3.0 so that if a different package requires it, great you can both use it! Thus a lot of version updates to your dependencies can be accepted without even thinking about it. However, when you see that v4.0.0, you know that your dependency broke some APIs, so you need to do that compatibility bump automatically. Thus the semvar system makes it much easier to maintain large organizations of packages since the number of manual version bumps that you need to do are rather small.

Because of how useful this can be, many package managers have incorporated a form of semantic versioning into its system. Julia’s package manager, Rust’s package manager, Node’s package manager, and more all have ways that integrate semantic versioning into its systems, making it easy to automatically accept dependency updates and thus keeper a wider set of compatibility than can effectively done manually. It’s a vital part of our current dependency system.

Okay if it’s great, then how can it be flawed?

Semver is flawed for two reasons:

  1. The definition of “breaking” is vague and ill-defined at its edges
  2. Current tooling does not accurately check for Semver compatibility

Breaking: a great concept but with unclear boundaries

The first point is somewhat known and is best characterized by the classic XKCD comic:

Any change can break code. It’s really up to the definition of “what is breaking”. There’s many nuances:

  • “Breaking” only applies to the “public facing API”, i.e. things that users interact with. If anything changing was considered breaking then every change would be a breaking change, so in order for semver to work you have to have some sense of what is considered internals and what is considered public. Julia in its next recent version has a new “public” keyword to declare certain internals as public, i.e. things which are exported and specifically chosen values in a package module are considered internal by default. If you have many users, you will still find someone say “but I use function __xxxxyyyz_internal because the API doesn’t allow me to pass mycacheunsafemathbeware optimally”, but at least you can blame them for it. This is the most solvable issue of semver and simply requires due diligence and sticking to a clear system for what’s exposed and what’s not. That’s a bit harder in dynamic languages, but as shown there are systems in place for this.
  • What is considered “breaking” in terms of functionality can have some fuzzy edges. I work on numerical solvers and connections to machine learning (scientific machine learning or SciML). If someone calls the ODE solver with abstol=1e-6 and reltol=1e-3, then what is returned is an approximation to the ODE’s solution with a few digits of accuracy (there’s some details in here I will ignore). If a change is made internally to the package, say more SIMD for better performance, which causes the result to change in the 12 digit, is that breaking? Because the ODE solver only is guaranteeing at most 3-6 digits of accuracy, probably not. But what if the 6th digit changes? The 5th? If the built-in sin function in the language changes in the 15th digit of accuracy, is that breaking? Most documentation strings don’t explicitly say “this is computed to 1ulp (units in the last place)”, so it’s not always clear what one is truly guaranteed from a numerical function. If someone improves the performance of a random number generator and now the random numbers for the same seed are different, is this breaking? Were you guaranteed that wasn’t going to change? People will argue all day about some of these edge cases, “it broke my tests because I set the random number seed and now it broke”. Look at any documentation, for example numpy.random.rand, and it won’t clarify these details on you can rely on to change and not change. This granularity is a discussion with a vague boundary.
  • One man’s bug fix is another man’s breaking change. You may have intended for all instantiations of f(x::T) (or T.f(x)) to return an integer, but one of them returned a float. So what do you do? You go fix it, make them all return floats, and add documentation on the interface that they all return a floating point value and implement some interface to enforce it across all functions… and then the issues roll in “you broke my code because I required that this version of the function returned an integer!”. A bugfix is by definition correcting an unintended behavior. However, someone has to define “unintended”, and your users may not be able to read your brain and may consider what was “intended” differently. I’m not sure there really is a solution to this because a bug is by definition unintended: if you knew it was there then you would have either fixed it or documented it earlier. But left with no documentation on what to do, the user may thing the behavior is intentional and use it.
  • Adding new functionality may have unintended consequences. You may have previously threw an error in a given case, but now return an approximation. The user may only want exact solutions to some math function f(x), so they relied on the error throw before in order to know if the solution would have been exactly calculable. Your new approximation functionality that you just released with a nice blog post thus just broke somebody’s code. So is it a major update, or a minor update? You never “intended” for only giving exact solutions, the error message might’ve even said “we intend to add this case in the near future with an approximation”, but you still broke their code.

As Churchill said, “democracy is the worst form of government, except for all the others”. In this case, semver is great because it conveys useful information, but we shouldn’t get ahead of ourselves and thus assume it does everything perfectly. Its definitions can be vague and it requires discussion to figure out whether something is breaking or a patch sometimes.

But if it does fail, hopefully our tooling can help us know. We all have continuous integration and continuous deployment (CI/CD), that helps us handle semver… right?

Standard CI/CD Systems are Insufficient to Check Semver Compatibility

I’m no chump so I set my versioning to use semantic versioning. My Project.tomls are all setup to put lower bounds, for example I list out all of my version requirements like a champ (if you’re not familiar with Julia’s package manager, everything defaults to semver and thus DiffEqBase = “6.41” in the compat implicitly means any 6.x with x>41, but 7 is not allowed). We laugh in the face of the Python PyPI system because our package registration system rejects any package (new or new version) which does not have an upper bound. Every package is required to have compatibilities specified, and thus random breakage is greatly reduced. We have forced all package authors to “do the right thing” and users have ultimately one. Package it up, we’re done here.

But then… users… see some breakage? They make a post where they show you that user your package failed. How could that happen? Well it goes back to part one that there are some edges in semantic versioning that may have creeped in somewhere. But many times what has happened is that the authors have simply forgotten what their lower bound means. v3.3.0 introduced the function f(x) in PkgA so when you started to use that function from the dependency, you set the lower bound there and life is good. g(x) was introduced in v3.4.0 and a few years later PkgA is at 3.11.2 you learn about it and go “cool PkgA is great!”, you start using g(x), your CI system says everything is fine, and then a user pops up and says your package is broken for them. When digging into the logs, you see that there’s some other package that only allows The Missing Link: Downgrade CI

The real core issue here is that semantic versioning is generally inadequately tested. In theory, if I put a lower bound saying I accept v3.3.0 and anything above it until v4, then I might be saying I am allowing more than 100 versions of PkgA. If I also have a PkgB with similar semantic versioning, I could be allowing 100 variations of that as well. However, the way everyone’s CI/CD system runs is to take the latest version of the packages. Okay, maybe for some major dependency like the standard library you list ‘Programming Language :: Python :: 3.8’, ‘Programming Language :: Python :: 3.9’, … to test multiple versions of that, but are you checking the 100,000 permutations of all allowed dependency versions? Almost certainly not. Not only have I not seen this, it’s also just infeasible to do in practice.

But as a shortcut, what you should be doing is at least checking the most edgy of edge cases. If I state v3.3.0 is my allowed lower bound, most CI systems will simply grab the latest v3.y.z with y and z as big as possible. However, I should at least have one run with v3.3.0 to see if it’s still sensible. This would have caught that g(x) was not defined in v3.4.0. While this wouldn’t fix all issues with semantic versioning, it can at least identify many of them pretty straightforwardly.

We call this scheme “Downgrade CI”, i.e. downgrade all dependencies to their minimum versions and run it. Most users will only ever see the maximum versions so sure it doesn’t matter to most people, but as people add more and more into their environment they will start to see earlier versions, and it’s these minimum versions that are the true key to whether your package will give a sensible environment, not the version maximums which semver puts so much effort into!

Setting up Downgrade CI

Okay, so hopefully I’ve convinced you that semver is not a magical solution to all compatibility problems, it’s a nice tool but not a silver bullet, and you need to have some form of downgrade CI. How do you actually accomplish this? Thankfully the Julia ecosystem has a julia-downgrade-compat-action which sets up Github Actions CI to automatically run the package versions with this downgrade idea in mind. If you’re scared of trying to figure that out, don’t worry and just copy-paste a script out of SciML. For example, from SciMLBase.jl:

name: Downgrade
on:
  pull_request:
    branches:
      - master
    paths-ignore:
      - 'docs/**'
  push:
    branches:
      - master
    paths-ignore:
      - 'docs/**'
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        version: ['1']
    steps:
      - uses: actions/checkout@v4
      - uses: julia-actions/setup-julia@v1
        with:
          version: ${{ matrix.version }}
      - uses: cjdoris/julia-downgrade-compat-action@v1
        with:
          skip: Pkg,TOML
      - uses: julia-actions/julia-buildpkg@v1
      - uses: julia-actions/julia-runtest@v1

This will add a new set of CI tests which run in the downgraded form and ensure your lower bounds are up to date. Will this solve all version compatibility issues? No, but hopefully this catches most of the major classes of issues.

Conclusion

In conclusion, use downgrade CI because semver isn’t perfect and while it does give a decent idea as to handling of upper bounds, lower bounds still need to be handled quite manually and “manual” is synonym for “can break”.

The post Semantic Versioning (Semver) is flawed, and Downgrade CI is required to fix it appeared first on Stochastic Lifestyle.

How to (Almost) Never Lose A Game

By: Alec Loudenback

Re-posted from: https://alecloudenback.com/posts/counting-chickens/index.html

Count Your Chickens is a cooperative game for children. I have very much enjoyed playing it with my daughter but an odd pattern appeared across many attempts: we never lost.

The game is entirely luck-based and is fairly straightforward. There are a bunch of chicks out of the chicken coop, and as you move from one space to another, you collect and return the chicks to the coop based on how many spaces you moved. You simply spin a spinner and move to the next icon that matches what you spun. There are some bonus spaces (in blue) where you get to collect an extra chick and you can also spin a fox which removes a chick from the coop.

I had a suspicion that if you were missing chicks from the game, that the game would quickly become much easier to “win” by getting all of the chicks back into the coop. Simultaneously, I had learned about SumTypes.jl and wanted to try it out. So could we simulate the game by using enumerated types? Yes, and here’s how it worked:

Setup

We’ll use four packages:

1using SumTypes
2using CairoMakie
3using ColorSchemes
4using DataFramesMeta
1
Used to model the different types of squares.
2
We’ll use this to plot outcomes of games.
3
To show the distribution of outcomes, we’ll use a custom color set for the plot.
4
Dataframe manipulation will help transform our simulated results for plotting.

Sum Types

What they are is nicely summarized as:

Sum types, sometimes called ‘tagged unions’ are the type system equivalent of the disjoint union operation (which is not a union in the traditional sense). In the Rust programming language, these are called “Enums”, and they’re more general than what Julia calls an enum.

At the end of the day, a sum type is really just a fancy word for a container that can store data of a few different, pre-declared types and is labeled by how it was instantiated.

Users of statically typed programming languages often prefer Sum types to unions because it makes type checking easier. In a dynamic language like Julia, the benefit of these objects is less obvious, but there are cases where they’re helpful, like performance sensitive branching on heterogeneous types, and enforcing the handling of cases.

We have two sets of things in this game which are similar and candidates for SumTypes:

  1. The animals on the spinner, and
  2. The different types of squares on the board.

It’s fairly simple for Animal, but Square needs a little explanation:

"Animal resprents the type of creature on the spinner and board."
@sum_type Animal begin
    Cow
    Tractor
    Sheep
    Dog
    Pig
    Fox
end

"""
Square represents the three different kinds of squares, including regular and bonus squares that contain data indicating the `Animal` in the square.
"""
@sum_type Square begin
    Empty
1    Regular(::Animal)
    Bonus(::Animal)
end
1
@sum_type will create variants that are all of the same type (Square in this case). The syntax Regular(::Animal) indicates that when, e.g, we create a Regular(Dog) we will get a Square that encloses data indicating it’s both a Regular variant of a Square in addition to holding the Dog instance of an Animal. That is, Regular(Dog) is an instance of Square type and does not create a distinct subtype of Square.

A couple of examples to show how this works:

typeof(Pig), Pig isa Animal
(Animal, true)
typeof(Bonus(Dog)), Bonus(Dog) isa Square
(Square, true)

Game Logic

I’ll first define a function that outlines how the game works and allow the number of chicks in play to vary since that’s the thesis for why it might be easier to win with missing pieces. Then I define two helper functions which give us the right behavior depending on the result of the spinner and the current state of the board. They are described in the docstrings.

"""
    playgame(board,total_chicks=40)

Simulate a game of Count Your Chickens and return how many chicks are outside of the coop at the end. The players win if there are no chicks outside of the coop. 
"""
function playgame(board, total_chicks=40)
    position = 0
    chicks_in_coop = 0
    while position < length(board)
        spin = rand((Cow, Tractor, Sheep, Dog, Pig, Fox))
        if spin == Fox
            if chicks_in_coop > 1
                chicks_in_coop -= 1
            end
        else
            result = move(board, position, spin)
            # limit the chicks in coop to available chicks remaining
            moved_chicks = min(total_chicks - chicks_in_coop, result.chicks)
            chicks_in_coop += moved_chicks
            position += result.spaces
        end
    end
    return total_chicks - chicks_in_coop

end
"""
    move(board,cur_position,spin)

Represents the result of a single turn of the game. 
Returns a named pair (tuple) of the number of spaces moved and chicks collected for that turn. 
"""
function move(board, cur_position, spin)
    next_square = findnext(space -> ismatch(space, spin), board, max(cur_position, 1))

    if isnothing(next_square)
        # nothing found that matches, so we must be at the end of the board
        l = length(board) - cur_position + 1
        (spaces=l, chicks=l)
    else
        n_spaces = next_square - cur_position
1        @cases board[next_square] begin
            Empty => (spaces=n_spaces, chicks=n_spaces)
            Bonus => (spaces=n_spaces, chicks=n_spaces + 1)
            Regular => (spaces=n_spaces, chicks=n_spaces)
        end
    end
end
1
SumTypes.jl provides a way to match the value of the board at the next square to Empty (which shouldn’t actually happen), Bonus, or Regular and the result depends on which kind of board we landed on.
"""
    ismatch(space,spin)

True or false depending on if the `spin` (an `Anmial`) matches the data within the `square` (`Animal` if not an `Empty` `Square`). 
"""
function ismatch(square, spin)
    @cases square begin
        Empty => false
1        [Regular, Bonus](a) => spin == a
    end
end
1
The [...] lets us simplify repeated cases while the (a) syntax allows us to reference the encapsulated data within the Square SumType.

Last part of the setup is declaring what the board looks like (unhide if you want to see – it’s just a long array representing each square on the board):

Code
board = [
    Empty,
    Regular(Sheep),
    Regular(Pig),
    Bonus(Tractor),
    Regular(Cow),
    Regular(Dog),
    Regular(Pig),
    Bonus(Cow),
    Regular(Dog),
    Regular(Sheep),
    Regular(Tractor),
    Empty,
    Regular(Cow),
    Regular(Pig),
    Empty,
    Empty,
    Empty,
    Regular(Tractor),
    Empty,
    Regular(Tractor),
    Regular(Dog),
    Bonus(Sheep),
    Regular(Cow),
    Regular(Dog),
    Regular(Pig),
    Regular(Tractor),
    Empty,
    Regular(Sheep),
    Regular(Cow),
    Empty,
    Empty,
    Regular(Tractor),
    Regular(Pig),
    Regular(Sheep),
    Bonus(Dog),
    Empty,
    Regular(Sheep),
    Regular(Cow),
    Bonus(Pig),]

Examples

Here are a couple examples of how the above works. First, here’s an example where we check if our spin (a Pig matches a candidate square Bonus(Pig)):

ismatch(Bonus(Pig), Pig)
true

If our first spin was a Pig, then we would move 3 spaces and collect 3 chicks:

move(board, 0, Pig)
(spaces = 3, chicks = 3)

And a simulation of a game:

playgame(board, 40)
0

Game Dynmaics

To understand the dynamics, we will simulate 1000 games for each variation of chicks from 35 (less than should come with the game) to 42 (more than should come with the game).

chick_range = 35:42
n = 1000
n_chicks = repeat(chick_range, n)
outcomes = playgame.(Ref(board), n_chicks)

df = DataFrame(; n_chicks, outcomes)


df = @chain df begin
    # create a wide table with the first column being the 
    # number of remaining chicks while the others 
    # total chicks
    unstack(:outcomes, :n_chicks, :outcomes, combine=length)
    # turn the missing values into 0 times this combination occurred
    coalesce.(_, 0)
    # # calculate proportion of outcomes within each column
    transform(Not(:outcomes) .=> x -> x / sum(x), renamecols=false)
    # restack back into a long table
    stack(Not(:outcomes))
end
# parse the column names which became strings when unstacked to column name
df.n_chicks = parse.(Int, df.variable)
df
104×4 DataFrame
79 rows omitted
Row outcomes variable value n_chicks
Int64 String Float64 Int64
1 0 35 0.986 35
2 2 35 0.004 35
3 1 35 0.01 35
4 5 35 0.0 35
5 3 35 0.0 35
6 6 35 0.0 35
7 4 35 0.0 35
8 7 35 0.0 35
9 8 35 0.0 35
10 14 35 0.0 35
11 11 35 0.0 35
12 9 35 0.0 35
13 10 35 0.0 35
93 2 42 0.22 42
94 1 42 0.186 42
95 5 42 0.07 42
96 3 42 0.188 42
97 6 42 0.028 42
98 4 42 0.113 42
99 7 42 0.011 42
100 8 42 0.01 42
101 14 42 0.001 42
102 11 42 0.001 42
103 9 42 0.002 42
104 10 42 0.0 42

Now to visualize the results, we want to create a custom color scheme where the color is green if we “win” and an increasingly intense red color the further we were from winning the game (not all chicks made it back to the coop).

colors = vcat(get(ColorSchemes.rainbow, 0.5), get.(Ref(ColorSchemes.Reds_9), 0.6:0.025:1.0))

let
    f = Figure()
    ax = Axis(f[1, 1],
        title="Count Your Chickens Win Rate",
        xticks=chick_range,
        xlabel="Number of chicks",
        ylabel="Proportion of games",
    )
    bp = barplot!(df.n_chicks, df.value,
        stack=df.outcomes,
        color=colors[df.outcomes.+1],
        label=df.outcomes,
    )

    f
end
┌ Warning: Found `resolution` in the theme when creating a `Scene`. The `resolution` keyword for `Scene`s and `Figure`s has been deprecated. Use `Figure(; size = ...` or `Scene(; size = ...)` instead, which better reflects that this is a unitless size and not a pixel resolution. The key could also come from `set_theme!` calls or related theming functions.
└ @ Makie ~/.julia/packages/Makie/fyNiH/src/scenes.jl:220

We can see that if we have 40 chicks (probably by design) we’d expect to win just over 50% of the time (which is less often that I would have guessed for the game’s family friendly approach).

However, if you were missing a few pieces like we were, your probability of winning dramatically increases and explains our family’s winning streak.

Endnotes

Environment

Julia Packages:

using Pkg;
Pkg.status();
Status `~/prog/alecloudenback.com/posts/counting-chickens/Project.toml`
  [13f3f980] CairoMakie v0.11.5
  [35d6a980] ColorSchemes v3.24.0
  [1313f7d8] DataFramesMeta v0.14.1
  [8e1ec7a9] SumTypes v0.5.5

Acknowledgements

Thanks to Mason Protter who provided some clarifications on the workings of SumTypes.jl.