This came up in my RSS feed today. The Julia code could do with a makeover for anyone interested.
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”).
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.
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.
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)
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.
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
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 (Two Heads in A Row: With Unequal Probability | ELEC424 Wiki | Fandom), 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
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).
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
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
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.
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
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.