Opportunity to write some idiomatic Julia code

This came up in my RSS feed today. The Julia code could do with a makeover for anyone interested.

3 Likes

Thanks, but to others looking at this, note, the Julia code is incorrect. You often don’t see it until you do (and I’ve only seen a negative value from the Ruby code):

$ ~/julia-1.4.0/bin/julia O6.jl
ERROR: LoadError: UndefVarError: t not defined

Stacktrace:
 [1] top-level scope at /home/pharaldsson_sym/O6.jl:18
 [2] include(::Module, ::String) at ./Base.jl:377
 [3] exec_options(::Base.JLOptions) at ./client.jl:288
 [4] _start() at ./client.jl:484
in expression starting at /home/pharaldsson_sym/O6.jl:16

Maybe helpful to see why, in Julia 1.5 or:

$ julia-1.6-DEV O6.jl
┌ Warning: Assignment to `m` in soft scope is ambiguous because a global variable by the same name exists: `m` will be treated as a new local. Disambiguate by using `local m` to suppress this warning or `global m` to assign to the existing global variable.
└ @ ~/O6.jl:8
┌ Warning: Assignment to `mark` in soft scope is ambiguous because a global variable by the same name exists: `mark` will be treated as a new local. Disambiguate by using `local mark` to suppress this warning or `global mark` to assign to the existing global variable.
└ @ ~/O6.jl:9
┌ Warning: Assignment to `d` in soft scope is ambiguous because a global variable by the same name exists: `d` will be treated as a new local. Disambiguate by using `local d` to suppress this warning or `global d` to assign to the existing global variable.
└ @ ~/O6.jl:12
[..]

It’s good to know about the new soft scope rules (that only apply in the REPL, as of 1.5), while I’ve been using 1.5 (and 1.6) for a while now, I’m first now seeing these warnings, that I’m not sure how to switch off (-q doesn’t), nor non-REPL or include(“O6.jl”).

1 Like
D(x) = rand(1:x)

function D(x, roll)
    previous_roll = roll
    roll = D(x)
    return roll, previous_roll
end

function O(x)
    main_roll, previous_roll = D(x, 0)
    mark = ""
    if main_roll == 1
        roll, previous_roll = D(x, main_roll)
        while roll <= (x ÷ 2)
            main_roll -= 1
            roll, previous_roll = D(x, roll)
            if roll == 1 && previous_roll == 1
                mark = " fumble"
            end
        end
    elseif main_roll == x
        roll, previous_roll = D(x, main_roll)
        while roll >= (x ÷ 2 + 1)
            main_roll += 1
            roll, previous_roll = D(x, roll)
            if roll == x && previous_roll == x
                mark = " critical"
            end
        end
    end
    println("$(main_roll)$(mark)")
end

I think this correctly implements the O(6) roll, but how Julian it is, I don’t really know (I’m a bit new to the language). I’m also adding a histogram of the results, ignoring fumbles and crits.

o6hist

2 Likes

This seems a lot easier (the non-Julia codes are specialized to 6-sided die, which is the only case described in the problem, so it makes sense to specialize the Julia code as well), and you can unify the 6 and 1 loops:

function O6()
    total = rand(1:6)
    critical = false
    if total == 6 || total == 1
        Δ = total == 6 ? 1 : -1
        prev = 6
        while true
            die = rand(1:6)
            die ≤ 3 && break # 1-3 or 4-6 are equivalent for 6-sided die
            critical = critical || (prev == die == 6)
            prev = die
            total += Δ
        end
    end
    return total, critical ? (total > 6 ? "critical" : "fumble") : ""
end

With a little statistics knowledge, you could eliminate the loop entirely and write an expression (probably based on randexp) to directly generate an integer equivalent to the number of consecutive true values from a sequence of rand(Bool) calls.

3 Likes

Yeah that last bit is what I’m working on now (though I think it might be easier to use rand(Geometric(0.5)). The trick is, I think, accounding for the marks (“fumble” and “crit”)

Yes, I missed that we needed to track this in my first reading; I amended the code to check for that, but it makes that statistics a lot more complicated to model without a loop.

An entry for fewest lines:

julia> D(n) = rand(1:n)
D (generic function with 1 method)

julia> O6() = begin
           mark = nothing
           s = t = D(6)
           s == 1 && while ((t′, t) = (t, D(6)); t in 1:3)
               s -= 1
               1 == t′ == t && (mark = :fumble)
           end
           s == 6 && while ((t′, t) = (t, D(6)); t in 4:6)
               s += 1
               6 == t′ == t && (mark = :critical)
           end
           return s, mark
       end
O6 (generic function with 1 method)
1 Like

Here is an abbreviated version of your code that merges the two loops:

function O6()
    s, t, crit = rand(1:6), 1, false
    s in (1,6) && while ((t′, t) = (t, rand(1:6)); t < 4)
       s += s ≥ 6 ? 1 : -1
       crit |= 1 == t′ == t
    end
    return s, crit ? (s > 6 ? :critical : :fumble) : nothing
end

which is shorter than the Forth code while being (I think) more readable.

6 Likes

My entry for “most readable”:

struct Roll{R} end
LowRoll  = Union{Roll{1},Roll{2},Roll{3}}
HighRoll = Union{Roll{4},Roll{5},Roll{6}}

roll() = Roll{rand(1:6)}()

O6()   = D6(roll())
O6(r0) = (r0 == 1 || r0 == 6) ? D6(r0, roll(), r0, "") : (r0, "")

O6(::LowRoll,   ::HighRoll, result, mark) = (result, mark)  # stop rolling
O6(::HighRoll,  ::LowRoll,  result, mark) = (result, mark)  # stop rolling
O6(::Roll{1},   ::Roll{1},  result, mark) = D6(Roll{1}(), roll(), result-1, "fumble")
O6(::Roll{6},   ::Roll{6},  result, mark) = D6(Roll{6}(), roll(), result+1, "critical")
O6(::LowRoll,  r::LowRoll,  result, mark) = D6(r, roll(), result-1, mark)
O6(::HighRoll, r::HighRoll, result, mark) = D6(r, roll(), result+1, mark)

Obviously that’s painfully slow. Assuming external packages are allowed, this one is much faster while still being quite readable:

using MLStyle

roll() = rand(1:6)

@active LowRoll(roll)   roll ≤ 3
@active HighRoll(roll)  roll ≥ 4

O6() = O6(roll())
O6(first_roll) = @match first_roll begin
    1 || 6 => O6(first_roll, roll(), first_roll)
    _      => (first_roll, nothing)
end

O6(r1, r2, result, mark=nothing) = @match (r1, r2) begin
    (1, 1)                   => O6(1,  roll(), result-1, Some("fumble"))
    (6, 6)                   => O6(6,  roll(), result+1, Some("critical"))
    (LowRoll(), LowRoll())   => O6(r2, roll(), result-1, mark)
    (HighRoll(), HighRoll()) => O6(r2, roll(), result+1, mark)
    _                        => (result, mark)
end
1 Like

I think I got the loopless version working. The mark probability can be seen as a function of the number of modifying steps. In this way, it is similar to the probability of getting two heads in a row after n throws of an unfair coin (in this case 1/3 heads, 2/3 tails). This probability I took from (https://elec424.fandom.com/wiki/Two_Heads_in_A_Row:_With_Unequal_Probability), although there’s not a derivation so I’m not entirely sure it’s correct. Nonetheless, here’s my revised code.

using Distributions
D(x) = rand(1:x)
P(m) = m <= 1 ? 0 : P(m - 1) + (1 - P(m - 1)) / 3 - (1 - P(m - 2)) * (2 / 9)
function O(x) 
    roll, modifier = D(x), rand(Geometric(0.5))
    if (roll == x || roll == 1) && rand() <= P(modifier)
        println(roll == x ? "$(roll + modifier) critical" : "$(roll - modifier) fumble")
    else
        println(roll == x ? roll + modifier : roll == 1 ? roll - modifier : roll)
    end
end

Unfortunately, it’s still not smaller than the looped version, and it certainly makes it less readable, so not sure if there’s any real benefit to this version

2 Likes

Thanks. This was really instructive for me. I am new to Julia but attracted to it for various reasons. This thread pushed my motivation to go deeper into Julia. BTW (it’s my GitHub repo - the O6 is a way for me to learn new languages).

10 Likes

Oh, also, just for kicks – here’s one which is (i) more compact than the Forth example, (ii) arguably much more readable, and (iii) quite fast (BenchmarkTools.jl gives it a median runtime of ~21ns):

using MLStyle

O6() = O6(rand(1:6))                                                               
O6(r0) = (r0 == 1 || r0 == 6) ? O6(r0, rand(1:6), r0) : (r0, nothing)
O6(r1, r2, result, mark=nothing) = @match (r1, r2) begin                         
    if r1 ≤ 3 && r2 ≤ 3 end => O6(r2, rand(1:6), result-1, r1 == r2 == 1 ? Some("fumble") : mark)
    if r1 ≥ 4 && r2 ≥ 4 end => O6(r2, rand(1:6), result+1, r1 == r2 == 6 ? Some("critical") : mark)
    _                       => (result, mark)
end
4 Likes

My entry for most compact code. I combined everything into one giant ternary statement, and since that felt like cheating, I split the line for (marginally) easier to read code

using Distributions
P(m) = m <= 1 ? 0 : P(m - 1) + (1 - P(m - 1)) / 3 - (1 - P(m - 2)) * (2 / 9)
function O(x) 
    r, m = rand(1:x), rand(Geometric(0.5))
    println(r == x ? (rand() <= P(m) ? "$(r + m) critical" : r + m) : 
        (r == 1 ? (rand() <= P(m) ? "$(r - m) fumble" : r - m) : r))
end
2 Likes

And another version of the same length, but not using any packages

rand_geometric(x = 0) = rand(Bool) ? rand_geometric(x + 1) : x
P(m) = m <= 1 ? 0 : P(m - 1) + (1 - P(m - 1)) / 3 - (1 - P(m - 2)) * (2 / 9)
function O(x) 
    r, m = rand(1:x), rand_geometric()
    println(r == x ? (rand() <= P(m) ? "$(r + m) critical" : r + m) : 
        (r == 1 ? (rand() <= P(m) ? "$(r - m) fumble" : r - m) : r))
end

Won’t the median runtime be counting only the cases where the first throw is 2-5 (because this accounts for more than 50% of the cases and they are the fastest cases)? If so, it’s not a very meaningful metric to look at because it excludes almost all of the code.

3 Likes

On a general note, one can treat all rolls beyond 1 and 6 in the same loop, with a roll of 4-6 adding 1 to an accumulating “open” score and only afterwards checking if that accumulated open score is to be treated as an addition or subtraction to the original dice roll (if the original dice roll was a 1, subtract the accumulated open score and add if the original roll was a 6). The same goes for Criticals and Fumbles, two consecutive 6s is a Critical if the original roll was a 6 and a Fumble if the original roll was a 1 (but remember to then treat the original roll of 1 as a “6” for this purpose). This is how I wrote the FOCAL program (for the HP-41 calculator).

Yes, several of the implementations above do this, e.g. this one.

I like this, this almost reads like Haskell :wink:

Conditional on the first draw being 1 or 6, the subsequent sequence length has geometric distribution with p = 1/2, but no simple closed form solution occurs to me for the probability of “critical” or “fumble” (eg conditional on the sequence length).

A standard trick would be formulating it as an absorbing Markov chain with the state being the current roll and whether critical/fumble has happened.

I know - just wanted to make it into a general note.

1 Like