Skip to content

Commit

Permalink
Merge pull request #12 from dmbates/properscoring
Browse files Browse the repository at this point in the history
Handle duplicate characters when scoring a guess

@JuliaRegistrator register()
  • Loading branch information
dmbates authored Mar 3, 2022
2 parents b0e4803 + f048f80 commit e973a3f
Show file tree
Hide file tree
Showing 8 changed files with 220 additions and 179 deletions.
4 changes: 4 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
## MixedModels v0.2.0 Release Notes

- Bugfix release Issue #8, *Incorrect scoring when characters are repeated in guess*
- Fixing this led to slightly better mean number of guesses to solution.
6 changes: 2 additions & 4 deletions Project.toml
Original file line number Diff line number Diff line change
@@ -1,21 +1,19 @@
name = "Wordlegames"
uuid = "1cb69566-e1cf-455f-a587-fd79a2e00f5a"
authors = ["Douglas Bates <[email protected]> and contributors"]
version = "0.1.1"
version = "0.2.0"

[deps]
AbstractTrees = "1520ce14-60c1-5f80-bbc7-55ef81b5835c"
DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0"
Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
Tables = "bd369af6-aec1-5ad0-b16a-f7cc5008161c"
ThreadsX = "ac1d9e8a-700a-412c-b207-f0111f4b6c0d"

[compat]
AbstractTrees = "0.3"
DataFrames = "1"
Primes = "0.5"
Tables = "1"
ThreadsX = "0.1"
julia = "1.7"

[extras]
Expand All @@ -25,4 +23,4 @@ Tables = "bd369af6-aec1-5ad0-b16a-f7cc5008161c"
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"

[targets]
test = ["AbstractTrees", "Test", "Tables", "Primes"]
test = ["AbstractTrees", "Primes", "Tables", "Test"]
148 changes: 73 additions & 75 deletions README.md

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion docs/src/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ GuessScore
bincounts!
entropy2
expectedpoolsize
Wordlegames.hasdups
optimalguess
playgame!
reset!
score
scorecolumn!
scoreupdate!
showgame!
tiles
Expand Down
86 changes: 7 additions & 79 deletions src/GamePool.jl
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ The fields are:
- `guesspool`: a `Vector{NTuple{N,Char}}` of potential guesses
- `validtargets`: a `BitVector` of valid targets in the `guesspool`
- `allscores`: a cache of pre-computed scores as a `Matrix{S}` of size `(sum(validtargets), length(guesspool))`
- `active`: a `BitVector`. The active pool of targets is `guesspool[active]`.
- `active`: `BitVector` of length `sum(validtargets)`. The active pool of targets is `guesspool[validtargets][active]`.
- `counts`: `Vector{Int}` of length `3 ^ N` in which bin counts are accumulated
- `guesses`: `Vector{GuessScore}` recording game play
- `hardmode`: `Bool` - should the game be played in "Hard Mode"?
Expand Down Expand Up @@ -61,11 +61,12 @@ function GamePool(
throw(ArgumentError("lengths of `guesspool` and `validtargets` must match"))
end
## enhancements - remove any duplicates in guesspool
allscores = ThreadsX.map(
t -> score(last(t), first(t)),
Iterators.product(view(guesspool, validtargets), guesspool),
)
S = eltype(allscores)
S = scoretype(N)
vtargs = view(guesspool, validtargets)
allscores = Array{S}(undef, length(vtargs), length(guesspool))
Threads.@threads for j in axes(allscores, 2)
scorecolumn!(view(allscores, :, j), guesspool[j], vtargs)
end
return updateguess!(
GamePool{N,S,guesstype}(
guesspool,
Expand Down Expand Up @@ -181,7 +182,6 @@ Return the optimal guess as a `Tuple{Int,Float64,Float64}` from
"""
function optimalguess(gp::GamePool{N,S,MaximizeEntropy}) where {N,S}
gind, xpctd, entrpy = 0, Inf, -Inf
poolsize = sum(gp.active)
for (k, a) in enumerate(gp.active)
if a
thisentropy = entropy2(bincounts!(gp, k))
Expand Down Expand Up @@ -269,66 +269,8 @@ function reset!(gp::GamePool)
return gp
end

"""
score(guess, target)
score(gp::GamePool, targetind::Integer)
Return a generalized Wordle score for `guess` at `target`, as an `Int` in `0:((3^length(zip(guess,target))) - 1)`.
The second method returns a precomputed score at `gp.allscores[targetind, last(gp.guessinds)]`.
In Wordle both `guess` and `target` would be length-5 character strings and each position
in `guess` is scored as green if it matches `target` in the same position, yellow if it
matches `target` in another position, and gray if there is no match. This function returns
such a score as a number whose base-3 representation is 0 for no match, 1 for a match in
another position and 2 for a match in the same position.
See also: [`tiles`](@ref) for converting this numeric score to colored tiles.
"""
function score(guess, target)
s = 0
for (g, t) in zip(guess, target)
s *= 3
s += (g == t ? 2 : Int(g target))
end
return s
end

function score(guess::NTuple{N}, target::NTuple{N}) where {N}
S = scoretype(N)
s = zero(S)
for i in 1:N
s *= S(3)
g = guess[i]
s += (g == target[i] ? S(2) : S(g target))
end
return s
end

score(gp::GamePool, targetind::Integer) = gp.allscores[targetind, last(gp.guesses).index]

"""
scoretype(nchar)
Return the smallest type `T<:Unsigned` for storing the scores from a pool of items of length `nchar`
"""
@inline function scoretype(nchar)
if nchar 0 || nchar > 80
throw(ArgumentError("nchar = $nchar is not in `1:80`"))
end
return if nchar 5
UInt8
elseif nchar 10
UInt16
elseif nchar 20
UInt32
elseif nchar 40
UInt64
else
UInt128
end
end

"""
scoreupdate!(gp::GamePool, sc::Integer)
scoreupdate!(gp::GamePool{N}, scv::Vector{<:Integer}) where {N}
Expand Down Expand Up @@ -367,20 +309,6 @@ end

showgame!(gp::GamePool) = showgame!(gp, rand(axes(gp.active, 1)))

"""
tiles(score, ntiles)
Return a length-`ntiles` `String` tile pattern from the numeric score `score`.
"""
function tiles(sc, ntiles)
result = sizehint!(Char[], ntiles) # initialize to an empty array of Char
for _ in 1:ntiles # _ indicates we won't use the value of the iterator
sc, r = divrem(sc, 3)
push!(result, iszero(r) ? '🟫' : (isone(r) ? '🟨' : '🟩'))
end
return String(reverse(result))
end

"""
updateguess!(gp::GamePool)
Expand Down
5 changes: 3 additions & 2 deletions src/Wordlegames.jl
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,17 @@ using AbstractTrees
using DataFrames
using Random
using Tables
using ThreadsX

using AbstractTrees: print_tree

include("utilities.jl")
include("GamePool.jl")
include("trees.jl")

export GameNode,
GamePool,
GuessScore,
GuessType,
MinimizeExpected,
MaximizeEntropy,
Random,
Expand All @@ -26,7 +27,7 @@ export GameNode,
print_tree,
reset!,
rowtable,
score,
scorecolumn!,
scoreupdate!,
showgame!,
tiles,
Expand Down
116 changes: 116 additions & 0 deletions src/utilities.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""
hasdups(guess::NTuple{N,Char}) where {N}
Returns `true` if there are duplicate characters in `guess`.
"""
function hasdups(guess::NTuple{N,Char}) where {N}
@inbounds for i in 1:(N - 1)
gi = guess[i]
for j in (i + 1):N
gi == guess[j] && return true
end
end
return false
end

"""
scorecolumn!(col, guess::NTuple{N,Char}, targets::AbstractVector{NTuple{N,Char}})
Return `col` updated with the scores of `guess` on each of the elements of `targets`
If there are no duplicate characters in `guess` a simple algorithm is used, otherwise
the more complex algorithm that accounts for duplicates is used.
"""
function scorecolumn!(
col::AbstractVector{<:Integer},
guess::NTuple{N,Char},
targets::AbstractVector{NTuple{N,Char}},
) where {N}
if axes(col) axes(targets)
throw(
DimensionMismatch("axes(col) = $(axes(col))$(axes(targets)) = axes(targets)")
)
end
if hasdups(guess)
onetoN = (1:N...,)
svec = zeros(Int, N) # scores for characters in guess
unused = trues(N) # has a character in targets[i] been used
@inbounds for i in axes(targets, 1)
targeti = targets[i]
fill!(unused, true)
fill!(svec, 0)
for j in 1:N # first pass for target in same position
if guess[j] == targeti[j]
unused[j] = false
svec[j] = 2
end
end
for j in 1:N # second pass for match in unused position
if iszero(svec[j])
for k in onetoN[unused]
if guess[j] == targeti[k]
svec[j] = 1
unused[k] = false
break
end
end
end
end
sc = 0 # similar to undup for evaluating score
for s in svec
sc *= 3
sc += s
end
col[i] = sc
end
else # simplified alg. for guess w/o duplicates
@inbounds for i in axes(targets, 1)
sc = 0
targeti = targets[i]
for j in 1:N
sc *= 3
gj = guess[j]
sc += (gj == targeti[j] ? 2 : gj targeti)
end
col[i] = sc
end
end
return col
end

"""
scoretype(nchar)
Return the smallest type `T<:Unsigned` for storing the scores from a pool of items of length `nchar`
"""
@inline function scoretype(nchar)
if nchar 0 || nchar > 80
throw(ArgumentError("nchar = $nchar is not in `1:80`"))
end
return if nchar 5
UInt8
elseif nchar 10
UInt16
elseif nchar 20
UInt32
elseif nchar 40
UInt64
else
UInt128
end
end

"""
tiles(score::Integer, ntiles::Integer)
tiles(svec::AbstractVector{<:Integer})
Return a length-`ntiles` `String` tile pattern from the numeric score `score`.
"""
function tiles(sc::Integer, ntiles)
result = sizehint!(Char[], ntiles) # initialize to an empty array of Char
for _ in 1:ntiles # _ indicates we won't use the value of the iterator
sc, r = divrem(sc, 3)
push!(result, iszero(r) ? '🟫' : (isone(r) ? '🟨' : '🟩'))
end
return String(reverse(result))
end
31 changes: 13 additions & 18 deletions test/runtests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ const primelxpc = GamePool(primes5; guesstype=MinimizeExpected)
@test entropy [6.632274058429609, 5.479367512099353, 3.121928094887362]
@test sc == [108, 112, 242]
(; poolsz, guess, index, expected, entropy, score, sc) = showgame!(primel, "43867")
@test index == [313, 2060, 3337]
@test index == [313, 2387, 3273, 3337]
# size mismatch
@test_throws ArgumentError playgame!(primel, "4321")
# errors in constructor arguments
Expand All @@ -54,23 +54,18 @@ const primelxpc = GamePool(primes5; guesstype=MinimizeExpected)
@test Tables.isrowtable(playgame!(primel).guesses) # this also covers the playgame! method for testing
end

@testset "score" begin
raiseS = "raise"
raiseN = NTuple{5,Char}(raiseS)
superS = "super"
superN = NTuple{5,Char}(superS)

@test score(raiseS, raiseS) == 242
@test score(raiseN, raiseN) == 242
@test score(raiseS, superS) == 85
@test score(raiseN, superS) == 85
@test score(raiseS, superN) == 85
@test score(raiseN, superN) == 85
reset!(primel)
@test score(primel, 1) == 0xa2
@test score(primel, 3426) == 0x09
@test_throws BoundsError score(primel, -1)
@test_throws BoundsError score(primel, 8364)
@testset "scorecolumn!" begin
targets = NTuple{5,Char}.(["raise", "super", "adapt", "algae", "abbey"])
scores = similar(targets, UInt8)
@test first(scorecolumn!(scores, targets[1], targets)) == 242
@test_throws DimensionMismatch scorecolumn!(zeros(UInt8,4), targets[1], targets)
@test scorecolumn!(scores, targets[3], targets)[3] == 242
targets = NTuple{5,Char}.(["12953", "34513", "51133", "51383"])
scores = scorecolumn!(similar(targets, UInt8), targets[4], targets)
@test first(scores) == 0x6e
@test last(scores) == 0xf2
scorecolumn!(scores, targets[2], targets)
@test last(scores) == 0x5f
end

@testset "scoretype" begin
Expand Down

2 comments on commit e973a3f

@dmbates
Copy link
Owner Author

@dmbates dmbates commented on e973a3f Mar 3, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JuliaRegistrator register()

@JuliaRegistrator
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Registration pull request created: JuliaRegistries/General/55905

After the above pull request is merged, it is recommended that a tag is created on this repository for the registered package version.

This will be done automatically if the Julia TagBot GitHub Action is installed, or can be done manually through the github interface, or via:

git tag -a v0.2.0 -m "<description of version>" e973a3ffb20ce9b2311f958a73ca4d316e5b25d0
git push origin v0.2.0

Please sign in to comment.