wordl-solver 1.4.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,252 @@
1
+ [
2
+ {
3
+ "word": "soare",
4
+ "score": 5.88596,
5
+ "is_possible_answer": false
6
+ },
7
+ {
8
+ "word": "roate",
9
+ "score": 5.882779,
10
+ "is_possible_answer": false
11
+ },
12
+ {
13
+ "word": "raise",
14
+ "score": 5.87791,
15
+ "is_possible_answer": true
16
+ },
17
+ {
18
+ "word": "raile",
19
+ "score": 5.86571,
20
+ "is_possible_answer": false
21
+ },
22
+ {
23
+ "word": "reast",
24
+ "score": 5.865457,
25
+ "is_possible_answer": false
26
+ },
27
+ {
28
+ "word": "slate",
29
+ "score": 5.855775,
30
+ "is_possible_answer": true
31
+ },
32
+ {
33
+ "word": "crate",
34
+ "score": 5.834874,
35
+ "is_possible_answer": true
36
+ },
37
+ {
38
+ "word": "salet",
39
+ "score": 5.834582,
40
+ "is_possible_answer": false
41
+ },
42
+ {
43
+ "word": "irate",
44
+ "score": 5.831397,
45
+ "is_possible_answer": true
46
+ },
47
+ {
48
+ "word": "trace",
49
+ "score": 5.830549,
50
+ "is_possible_answer": true
51
+ },
52
+ {
53
+ "word": "arise",
54
+ "score": 5.82094,
55
+ "is_possible_answer": true
56
+ },
57
+ {
58
+ "word": "orate",
59
+ "score": 5.817161,
60
+ "is_possible_answer": false
61
+ },
62
+ {
63
+ "word": "stare",
64
+ "score": 5.80728,
65
+ "is_possible_answer": true
66
+ },
67
+ {
68
+ "word": "carte",
69
+ "score": 5.794557,
70
+ "is_possible_answer": false
71
+ },
72
+ {
73
+ "word": "raine",
74
+ "score": 5.78671,
75
+ "is_possible_answer": false
76
+ },
77
+ {
78
+ "word": "caret",
79
+ "score": 5.776713,
80
+ "is_possible_answer": false
81
+ },
82
+ {
83
+ "word": "ariel",
84
+ "score": 5.775167,
85
+ "is_possible_answer": false
86
+ },
87
+ {
88
+ "word": "taler",
89
+ "score": 5.770612,
90
+ "is_possible_answer": false
91
+ },
92
+ {
93
+ "word": "carle",
94
+ "score": 5.770479,
95
+ "is_possible_answer": false
96
+ },
97
+ {
98
+ "word": "slane",
99
+ "score": 5.770181,
100
+ "is_possible_answer": false
101
+ },
102
+ {
103
+ "word": "snare",
104
+ "score": 5.770089,
105
+ "is_possible_answer": true
106
+ },
107
+ {
108
+ "word": "artel",
109
+ "score": 5.768295,
110
+ "is_possible_answer": false
111
+ },
112
+ {
113
+ "word": "arose",
114
+ "score": 5.767797,
115
+ "is_possible_answer": true
116
+ },
117
+ {
118
+ "word": "strae",
119
+ "score": 5.767281,
120
+ "is_possible_answer": false
121
+ },
122
+ {
123
+ "word": "carse",
124
+ "score": 5.765364,
125
+ "is_possible_answer": false
126
+ },
127
+ {
128
+ "word": "saine",
129
+ "score": 5.764078,
130
+ "is_possible_answer": false
131
+ },
132
+ {
133
+ "word": "earst",
134
+ "score": 5.757081,
135
+ "is_possible_answer": false
136
+ },
137
+ {
138
+ "word": "taser",
139
+ "score": 5.752976,
140
+ "is_possible_answer": false
141
+ },
142
+ {
143
+ "word": "least",
144
+ "score": 5.751646,
145
+ "is_possible_answer": true
146
+ },
147
+ {
148
+ "word": "alert",
149
+ "score": 5.745837,
150
+ "is_possible_answer": true
151
+ },
152
+ {
153
+ "word": "crane",
154
+ "score": 5.742782,
155
+ "is_possible_answer": true
156
+ },
157
+ {
158
+ "word": "tares",
159
+ "score": 5.742548,
160
+ "is_possible_answer": false
161
+ },
162
+ {
163
+ "word": "seral",
164
+ "score": 5.739455,
165
+ "is_possible_answer": false
166
+ },
167
+ {
168
+ "word": "stale",
169
+ "score": 5.738573,
170
+ "is_possible_answer": true
171
+ },
172
+ {
173
+ "word": "saner",
174
+ "score": 5.733713,
175
+ "is_possible_answer": true
176
+ },
177
+ {
178
+ "word": "ratel",
179
+ "score": 5.73087,
180
+ "is_possible_answer": false
181
+ },
182
+ {
183
+ "word": "torse",
184
+ "score": 5.72338,
185
+ "is_possible_answer": false
186
+ },
187
+ {
188
+ "word": "tears",
189
+ "score": 5.718023,
190
+ "is_possible_answer": false
191
+ },
192
+ {
193
+ "word": "resat",
194
+ "score": 5.716071,
195
+ "is_possible_answer": false
196
+ },
197
+ {
198
+ "word": "alter",
199
+ "score": 5.713171,
200
+ "is_possible_answer": true
201
+ },
202
+ {
203
+ "word": "later",
204
+ "score": 5.706089,
205
+ "is_possible_answer": true
206
+ },
207
+ {
208
+ "word": "prate",
209
+ "score": 5.700636,
210
+ "is_possible_answer": false
211
+ },
212
+ {
213
+ "word": "trine",
214
+ "score": 5.697082,
215
+ "is_possible_answer": false
216
+ },
217
+ {
218
+ "word": "react",
219
+ "score": 5.696354,
220
+ "is_possible_answer": true
221
+ },
222
+ {
223
+ "word": "saice",
224
+ "score": 5.691244,
225
+ "is_possible_answer": false
226
+ },
227
+ {
228
+ "word": "toile",
229
+ "score": 5.691014,
230
+ "is_possible_answer": false
231
+ },
232
+ {
233
+ "word": "earnt",
234
+ "score": 5.690444,
235
+ "is_possible_answer": false
236
+ },
237
+ {
238
+ "word": "trone",
239
+ "score": 5.684949,
240
+ "is_possible_answer": false
241
+ },
242
+ {
243
+ "word": "leant",
244
+ "score": 5.684574,
245
+ "is_possible_answer": true
246
+ },
247
+ {
248
+ "word": "liane",
249
+ "score": 5.68423,
250
+ "is_possible_answer": false
251
+ }
252
+ ]
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../wordl_solver"
5
+ require_relative "feedback"
6
+ require_relative "game_state"
7
+ require_relative "filter"
8
+ require_relative "scorer"
9
+ require_relative "word_lists"
10
+
11
+ module WordlSolver
12
+ class CLI
13
+ DEFAULT_TOP = 10
14
+ OPENING_SCORES_PATH = File.expand_path("../data/opening_scores.json", __dir__)
15
+
16
+ def self.run(argv = ARGV, input: $stdin, output: $stdout, error: $stderr)
17
+ top = parse_top(argv) || DEFAULT_TOP
18
+ new(input: input, output: output, error: error, top: top).run
19
+ end
20
+
21
+ def self.parse_top(argv)
22
+ idx = argv.index("--top")
23
+ return nil unless idx
24
+
25
+ value = argv[idx + 1]
26
+ raise ArgumentError, "--top requires a positive integer argument" if value.nil?
27
+
28
+ parsed = Integer(value)
29
+ raise ArgumentError, "--top must be a positive integer, got #{value.inspect}" unless parsed.positive?
30
+
31
+ parsed
32
+ end
33
+
34
+ def initialize(input:, output:, error:, top: DEFAULT_TOP)
35
+ @in = input
36
+ @out = output
37
+ @err = error
38
+ @top = top
39
+ @state = GameState.new
40
+ end
41
+
42
+ def run
43
+ print_banner
44
+ loop do
45
+ candidates = current_candidates
46
+ print_turn_header(candidates)
47
+ print_suggestions(candidates)
48
+ return if @state.solved?
49
+
50
+ line = read_line
51
+ return if line.nil?
52
+
53
+ case line
54
+ when ":quit", ":q"
55
+ return
56
+ when ":help", ":h", ":?"
57
+ print_help
58
+ when ":undo", ":u"
59
+ handle_undo
60
+ when ":restart", ":r"
61
+ @state = GameState.new
62
+ else
63
+ handle_guess(line)
64
+ end
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ def print_banner
71
+ @out.puts "wordl-solver #{WordlSolver::VERSION}"
72
+ @out.puts "Type: <word> <pattern> (pattern is 5 chars from g/y/s)"
73
+ @out.puts "Commands: :undo :restart :quit :help"
74
+ @out.puts
75
+ end
76
+
77
+ def print_help
78
+ @out.puts
79
+ @out.puts " <word> <pattern> submit a guess, e.g. crane sygss"
80
+ @out.puts " pattern chars: g=green, y=yellow, s=grey (b and . also accepted for grey)"
81
+ @out.puts " :undo remove the last turn"
82
+ @out.puts " :restart start over"
83
+ @out.puts " :quit exit"
84
+ @out.puts " :help this message"
85
+ @out.puts
86
+ end
87
+
88
+ def print_turn_header(candidates)
89
+ turn_num = @state.turns.size + 1
90
+ @out.puts "Turn #{turn_num} — #{candidates.size} possible answer#{'s' unless candidates.size == 1}."
91
+ end
92
+
93
+ def print_suggestions(candidates)
94
+ if @state.solved?
95
+ @out.puts " Solved! 🎉"
96
+ return
97
+ end
98
+
99
+ if candidates.empty?
100
+ @out.puts " No candidates remain. Did a guess get an incorrect pattern? Try :undo."
101
+ return
102
+ end
103
+
104
+ ranked = if @state.empty?
105
+ opening_scores
106
+ else
107
+ Scorer.rank(candidates: candidates, guess_pool: WordLists::ALLOWED_GUESSES)
108
+ end
109
+
110
+ shown = ranked.first(@top)
111
+ possible = shown.select(&:is_possible_answer)
112
+ others = shown - possible
113
+
114
+ show_bits = candidates.size > 2
115
+ formatter = ->(sw) { show_bits ? format(" %-6s %.2f bits", sw.word, sw.score) : " #{sw.word}" }
116
+
117
+ if possible.any?
118
+ @out.puts " Possible answers:"
119
+ possible.each { |sw| @out.puts formatter.call(sw) }
120
+ end
121
+ if others.any?
122
+ @out.puts " High-info guesses:"
123
+ others.each { |sw| @out.puts "#{formatter.call(sw)} (not a possible answer)" }
124
+ end
125
+ @out.puts
126
+ end
127
+
128
+ def current_candidates
129
+ Filter.candidates(WordLists::ANSWERS, @state)
130
+ end
131
+
132
+ def opening_scores
133
+ @opening_scores ||= JSON.parse(File.read(OPENING_SCORES_PATH)).map do |row|
134
+ Scorer::ScoredWord.new(row["word"], row["score"], row["is_possible_answer"])
135
+ end
136
+ end
137
+
138
+ def read_line
139
+ @out.print "> "
140
+ @out.flush
141
+ line = @in.gets
142
+ return nil if line.nil?
143
+
144
+ line.strip
145
+ end
146
+
147
+ def handle_undo
148
+ if @state.empty?
149
+ @out.puts " Nothing to undo."
150
+ else
151
+ @state = @state.undo
152
+ end
153
+ end
154
+
155
+ def handle_guess(line)
156
+ parts = line.split(/\s+/, 2)
157
+ if parts.size != 2
158
+ @out.puts " Expected: <word> <pattern>. Try again or :help."
159
+ return
160
+ end
161
+
162
+ word, pattern = parts
163
+ word = word.downcase
164
+
165
+ unless Filter.valid_guess?(word)
166
+ @out.puts " #{word.inspect} is not a valid 5-letter word."
167
+ return
168
+ end
169
+
170
+ feedback = begin
171
+ Feedback.parse(pattern.downcase)
172
+ rescue ArgumentError => e
173
+ @out.puts " Bad pattern: #{e.message}"
174
+ return
175
+ end
176
+
177
+ @state = @state.apply(guess: word, feedback: feedback)
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WordlSolver
4
+ class Feedback
5
+ VALID_COLORS = %i[green yellow grey].freeze
6
+ CHAR_TO_COLOR = {
7
+ "g" => :green,
8
+ "y" => :yellow,
9
+ "s" => :grey,
10
+ "b" => :grey,
11
+ "." => :grey
12
+ }.freeze
13
+ COLOR_TO_CHAR = { green: "g", yellow: "y", grey: "s" }.freeze
14
+
15
+ attr_reader :colors
16
+
17
+ def self.parse(string)
18
+ raise ArgumentError, "feedback must be 5 chars, got #{string.inspect}" unless string.length == 5
19
+
20
+ colors = string.chars.map do |ch|
21
+ CHAR_TO_COLOR[ch] || raise(ArgumentError, "invalid feedback char #{ch.inspect}")
22
+ end
23
+ new(colors)
24
+ end
25
+
26
+ def initialize(colors)
27
+ raise ArgumentError, "feedback must be 5 colors" unless colors.length == 5
28
+
29
+ colors.each do |c|
30
+ raise ArgumentError, "invalid color #{c.inspect}" unless VALID_COLORS.include?(c)
31
+ end
32
+
33
+ @colors = colors.freeze
34
+ freeze
35
+ end
36
+
37
+ def [](index)
38
+ @colors[index]
39
+ end
40
+
41
+ def all_green?
42
+ @colors.all? { |c| c == :green }
43
+ end
44
+
45
+ def to_s
46
+ @colors.map { |c| COLOR_TO_CHAR.fetch(c) }.join
47
+ end
48
+
49
+ def ==(other)
50
+ other.is_a?(Feedback) && other.colors == @colors
51
+ end
52
+ alias eql? ==
53
+
54
+ def hash
55
+ @colors.hash
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "word_lists"
4
+
5
+ module WordlSolver
6
+ module Filter
7
+ # Pure function. Returns the subset of word_list consistent with every turn
8
+ # in game_state.
9
+ def self.candidates(word_list, game_state)
10
+ return word_list.dup if game_state.empty?
11
+
12
+ constraints = game_state.turns.map { |turn| compile_constraints(turn.guess, turn.feedback) }
13
+
14
+ word_list.select do |word|
15
+ constraints.all? { |c| satisfies?(word, c) }
16
+ end
17
+ end
18
+
19
+ def self.valid_guess?(word)
20
+ WordLists::ALLOWED_GUESSES_SET.include?(word)
21
+ end
22
+
23
+ # --- internals --------------------------------------------------------
24
+
25
+ # Compile one turn into a fast-checkable constraint bundle:
26
+ # greens: Hash{position => required_letter}
27
+ # yellows: Hash{position => forbidden_letter_at_this_position}
28
+ # counts: Hash{letter => [min_count, has_grey]}
29
+ def self.compile_constraints(guess, feedback)
30
+ greens = {}
31
+ yellows = {}
32
+ per_letter = Hash.new { |h, k| h[k] = { min: 0, grey: false } }
33
+
34
+ 5.times do |i|
35
+ letter = guess[i]
36
+ case feedback[i]
37
+ when :green
38
+ greens[i] = letter
39
+ per_letter[letter][:min] += 1
40
+ when :yellow
41
+ yellows[i] = letter
42
+ per_letter[letter][:min] += 1
43
+ when :grey
44
+ per_letter[letter][:grey] = true
45
+ end
46
+ end
47
+
48
+ counts = per_letter.transform_values { |v| [v[:min], v[:grey]] }
49
+ { greens: greens, yellows: yellows, counts: counts }
50
+ end
51
+
52
+ def self.satisfies?(word, c)
53
+ c[:greens].each { |i, ch| return false unless word[i] == ch }
54
+ c[:yellows].each { |i, ch| return false if word[i] == ch }
55
+ c[:counts].each do |letter, (min, has_grey)|
56
+ actual = word.count(letter)
57
+ if has_grey
58
+ return false unless actual == min
59
+ else
60
+ return false unless actual >= min
61
+ end
62
+ end
63
+ true
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "feedback"
4
+
5
+ module WordlSolver
6
+ Turn = Struct.new(:guess, :feedback) do
7
+ def initialize(guess, feedback)
8
+ super(guess.freeze, feedback)
9
+ freeze
10
+ end
11
+ end
12
+
13
+ class GameState
14
+ attr_reader :turns
15
+
16
+ def initialize(turns = [])
17
+ @turns = turns.freeze
18
+ freeze
19
+ end
20
+
21
+ def empty?
22
+ @turns.empty?
23
+ end
24
+
25
+ def solved?
26
+ !@turns.empty? && @turns.last.feedback.all_green?
27
+ end
28
+
29
+ def apply(guess:, feedback:)
30
+ GameState.new(@turns + [Turn.new(guess, feedback)])
31
+ end
32
+
33
+ def undo
34
+ raise ArgumentError, "cannot undo: state is empty" if @turns.empty?
35
+
36
+ GameState.new(@turns[0...-1])
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require_relative "../wordl_solver"
5
+
6
+ module WordlSolver
7
+ module Scorer
8
+ ScoredWord = Struct.new(:word, :score, :is_possible_answer)
9
+
10
+ # Returns an Array<ScoredWord> sorted by score descending.
11
+ # For each guess in the guess pool, computes Shannon entropy of the
12
+ # distribution of feedback patterns it would produce against `candidates`.
13
+ def self.rank(candidates:, guess_pool:)
14
+ return [] if candidates.empty?
15
+
16
+ candidate_set = candidates.to_set
17
+ if candidates.size <= 2
18
+ return candidates.map { |w| ScoredWord.new(w, 0.0, true) }
19
+ end
20
+
21
+ effective_pool = candidates.size <= 50 ? candidates : guess_pool
22
+
23
+ n = candidates.size.to_f
24
+ effective_pool.map do |guess|
25
+ counts = Hash.new(0)
26
+ candidates.each do |secret|
27
+ counts[WordlSolver.score(guess, secret)] += 1
28
+ end
29
+ entropy = counts.each_value.sum(0.0) do |c|
30
+ p = c / n
31
+ -p * Math.log2(p)
32
+ end
33
+ ScoredWord.new(guess, entropy, candidate_set.include?(guess))
34
+ end.sort_by { |sw| -sw.score }
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WordlSolver
4
+ VERSION = "2.0.0"
5
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module WordlSolver
6
+ module WordLists
7
+ DATA_DIR = File.expand_path("../data", __dir__)
8
+
9
+ ANSWERS = File.readlines(File.join(DATA_DIR, "answers.txt"), chomp: true).each(&:freeze).freeze
10
+ ALLOWED_GUESSES = File.readlines(File.join(DATA_DIR, "allowed_guesses.txt"), chomp: true).each(&:freeze).freeze
11
+ ALLOWED_GUESSES_SET = Set.new(ALLOWED_GUESSES).freeze
12
+ end
13
+ end