codewords_solver 0.0.1
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.
- checksums.yaml +7 -0
- data/.gitignore +1 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +58 -0
- data/README.md +58 -0
- data/Rakefile +6 -0
- data/codewords_solver.gemspec +34 -0
- data/lib/codewords_solver/dictionary.rb +36 -0
- data/lib/codewords_solver/version.rb +3 -0
- data/lib/codewords_solver.rb +150 -0
- data/word_list.txt +9587 -0
- metadata +70 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 752df23fecf9cad700d45c1123280c1cb5abefaa6b6451d7b115c2e8b9923f83
|
4
|
+
data.tar.gz: 88e769f70915d86deb0f999b03a7207891858e88dbb27b2844109ed40e16d41a
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: fb6626bd1b4a6b9f59fbdc536c66babe61b2f97bc58d4e3927b1ef8cb1ab7284e0ddae85f4d3239e8c59a57066ecfc60d9d5656952d1535a9f1c33471c310f78
|
7
|
+
data.tar.gz: a59f255b6b4306082db65a6c50bca75d5969aee5fadf0b9c01f9f0fa1ab983f60ed5ae56174c8e175461bf29e33a0659ad7e9c4599e6294a7c7af19d5d6c74b6
|
data/.gitignore
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
pkg
|
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
@@ -0,0 +1,58 @@
|
|
1
|
+
PATH
|
2
|
+
remote: .
|
3
|
+
specs:
|
4
|
+
codewords_solver (0.0.1)
|
5
|
+
activesupport (~> 7.1)
|
6
|
+
|
7
|
+
GEM
|
8
|
+
remote: https://rubygems.org/
|
9
|
+
specs:
|
10
|
+
activesupport (7.1.3)
|
11
|
+
base64
|
12
|
+
bigdecimal
|
13
|
+
concurrent-ruby (~> 1.0, >= 1.0.2)
|
14
|
+
connection_pool (>= 2.2.5)
|
15
|
+
drb
|
16
|
+
i18n (>= 1.6, < 2)
|
17
|
+
minitest (>= 5.1)
|
18
|
+
mutex_m
|
19
|
+
tzinfo (~> 2.0)
|
20
|
+
base64 (0.2.0)
|
21
|
+
bigdecimal (3.1.5)
|
22
|
+
concurrent-ruby (1.2.3)
|
23
|
+
connection_pool (2.4.1)
|
24
|
+
diff-lcs (1.5.0)
|
25
|
+
drb (2.2.0)
|
26
|
+
ruby2_keywords
|
27
|
+
i18n (1.14.1)
|
28
|
+
concurrent-ruby (~> 1.0)
|
29
|
+
minitest (5.21.1)
|
30
|
+
mutex_m (0.2.0)
|
31
|
+
rake (13.1.0)
|
32
|
+
rspec (3.12.0)
|
33
|
+
rspec-core (~> 3.12.0)
|
34
|
+
rspec-expectations (~> 3.12.0)
|
35
|
+
rspec-mocks (~> 3.12.0)
|
36
|
+
rspec-core (3.12.2)
|
37
|
+
rspec-support (~> 3.12.0)
|
38
|
+
rspec-expectations (3.12.3)
|
39
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
40
|
+
rspec-support (~> 3.12.0)
|
41
|
+
rspec-mocks (3.12.6)
|
42
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
43
|
+
rspec-support (~> 3.12.0)
|
44
|
+
rspec-support (3.12.1)
|
45
|
+
ruby2_keywords (0.0.5)
|
46
|
+
tzinfo (2.0.6)
|
47
|
+
concurrent-ruby (~> 1.0)
|
48
|
+
|
49
|
+
PLATFORMS
|
50
|
+
arm64-darwin-22
|
51
|
+
|
52
|
+
DEPENDENCIES
|
53
|
+
codewords_solver!
|
54
|
+
rake (~> 13.0)
|
55
|
+
rspec (~> 3.0)
|
56
|
+
|
57
|
+
BUNDLED WITH
|
58
|
+
2.4.10
|
data/README.md
ADDED
@@ -0,0 +1,58 @@
|
|
1
|
+
# Codewords solver
|
2
|
+
|
3
|
+
A simple ruby-based allgorithm for solving codewords (AKA [Cypher crosswords](https://en.wikipedia.org/wiki/Crossword#Cipher_crosswords)) - popular in many newspapers.
|
4
|
+
|
5
|
+
## Usage
|
6
|
+
|
7
|
+
```ruby
|
8
|
+
require "codewords_solver"
|
9
|
+
|
10
|
+
CodewordsSolver.solve! coded_words: grid, starting_letters: { 5 => "F", 7 => "L" }
|
11
|
+
```
|
12
|
+
or
|
13
|
+
```ruby
|
14
|
+
require "codewords_solver"
|
15
|
+
|
16
|
+
solver = CodewordsSolver.new grid, { 5 => "F", 7 => "L" }
|
17
|
+
solver.solve!
|
18
|
+
```
|
19
|
+
where
|
20
|
+
```ruby
|
21
|
+
grid = [
|
22
|
+
[1, 13, 7, 11],
|
23
|
+
[22, 5, 5, 13, 10, 17, 4, 15],
|
24
|
+
[4, 13, 18, 3, 23],
|
25
|
+
[16, 13, 7, 7, 13, 22, 26],
|
26
|
+
[15, 10, 4, 12, 24, 25, 7, 17],
|
27
|
+
[25, 13, 4, 22],
|
28
|
+
[12, 24, 22, 9, 26, 23],
|
29
|
+
[5, 13, 12, 26, 10, 17],
|
30
|
+
[17, 26, 21, 14],
|
31
|
+
[24, 12, 4, 23, 13, 12, 26, 15],
|
32
|
+
[19, 4, 22, 8, 17, 10, 23],
|
33
|
+
[4, 12, 13, 15, 17],
|
34
|
+
[11, 13, 15, 10, 22, 9, 26, 23],
|
35
|
+
[2, 9, 13, 19],
|
36
|
+
[1, 22, 4, 20, 15, 19, 12, 10, 17],
|
37
|
+
[19, 22, 11],
|
38
|
+
[7, 12, 18, 17, 4],
|
39
|
+
[22, 25, 21, 13, 22, 9, 15],
|
40
|
+
[15, 23, 12, 24, 13, 26, 12],
|
41
|
+
[7, 17, 18, 22],
|
42
|
+
[5, 13, 16, 16, 7, 17],
|
43
|
+
[23, 12, 4, 23, 12, 26],
|
44
|
+
[13, 11, 7, 17],
|
45
|
+
[24, 13, 6, 23, 9, 4, 17],
|
46
|
+
[17, 11, 13, 23, 13, 22, 26],
|
47
|
+
[12, 11, 13, 17, 9],
|
48
|
+
[15, 22, 26],
|
49
|
+
[22, 21, 17, 4, 15, 7, 17, 17, 19],
|
50
|
+
]
|
51
|
+
```
|
52
|
+
**Note that while a codewords puzzle is typically formed into a grid with intersecting words, you do not need to mark such intersections here. A simple array of each of the numbered words will suffice!**
|
53
|
+
|
54
|
+
### Please note
|
55
|
+
|
56
|
+
A good codewords solver requires a good word list, and this one is far from perfect. If it isn't
|
57
|
+
solving a puzzle or it's making a mistake, it's quite likely that it's because there are words
|
58
|
+
missing from the list!
|
data/Rakefile
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "lib/codewords_solver/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = "codewords_solver"
|
7
|
+
spec.version = CodewordsSolver::VERSION
|
8
|
+
spec.authors = ["Joseph Johansen"]
|
9
|
+
spec.email = ["joe@stotles.com"]
|
10
|
+
|
11
|
+
spec.summary = "A simple algorithm for solving codeword puzzles"
|
12
|
+
spec.description = "A simple algorithm for solving codeword puzzles"
|
13
|
+
spec.homepage = "https://github.com/johansenja/codewords_solver"
|
14
|
+
spec.license = "MIT"
|
15
|
+
spec.required_ruby_version = Gem::Requirement.new(">= 2.7.0")
|
16
|
+
|
17
|
+
spec.metadata["allowed_push_host"] = "https://rubygems.org"
|
18
|
+
|
19
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
20
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
21
|
+
|
22
|
+
# Specify which files should be added to the gem when it is released.
|
23
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
24
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
25
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
|
26
|
+
end
|
27
|
+
spec.require_paths = ["lib"]
|
28
|
+
|
29
|
+
# Uncomment to register a new dependency of your gem
|
30
|
+
spec.add_dependency "activesupport", "~> 7.1"
|
31
|
+
|
32
|
+
# For more information and examples about making a new gem, checkout our
|
33
|
+
# guide at: https://bundler.io/guides/creating_gem.html
|
34
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
require "active_support/inflector"
|
2
|
+
|
3
|
+
class CodewordsSolver
|
4
|
+
class Dictionary
|
5
|
+
include ActiveSupport::Inflector
|
6
|
+
|
7
|
+
def initialize
|
8
|
+
@words = import_words_by_filepath("./word_list.txt")
|
9
|
+
end
|
10
|
+
|
11
|
+
def find_by_regexp(regexp)
|
12
|
+
all = @words.filter { |w| w.length > 2 and w.match? regexp }
|
13
|
+
end
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
def import_words_by_filepath(filepath)
|
18
|
+
# puts "Loading words in from #{filepath}"
|
19
|
+
file = File.read(filepath)
|
20
|
+
file.chomp!
|
21
|
+
|
22
|
+
words = file.split("\n")
|
23
|
+
|
24
|
+
words.map! do |w|
|
25
|
+
w.upcase!
|
26
|
+
w = transliterate(w, "")
|
27
|
+
w.gsub!(/[^A-Z]/, "")
|
28
|
+
w
|
29
|
+
end
|
30
|
+
|
31
|
+
# puts "Loaded #{words.length} words"
|
32
|
+
|
33
|
+
words
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,150 @@
|
|
1
|
+
require "codewords_solver/dictionary"
|
2
|
+
|
3
|
+
class CodewordsSolver
|
4
|
+
def self.solve!(coded_words:, starting_letters:)
|
5
|
+
new(coded_words, starting_letters).solve!
|
6
|
+
end
|
7
|
+
|
8
|
+
MAX_LOOP_ATTEMPTS = 5
|
9
|
+
|
10
|
+
def initialize(coded_words, starting_letters)
|
11
|
+
@coded_words = coded_words
|
12
|
+
@starting_letters = starting_letters
|
13
|
+
@letters_by_number = (1..26).to_h { |n| [n, nil] }.merge(
|
14
|
+
starting_letters.transform_values(&:upcase)
|
15
|
+
)
|
16
|
+
@dictionary = Dictionary.new
|
17
|
+
end
|
18
|
+
|
19
|
+
def solve!
|
20
|
+
num_attempts = 0
|
21
|
+
|
22
|
+
until complete? || num_attempts == MAX_LOOP_ATTEMPTS
|
23
|
+
# puts "Doing a loop (attempts so far: #{num_attempts})"
|
24
|
+
do_loop
|
25
|
+
num_attempts += 1
|
26
|
+
end
|
27
|
+
|
28
|
+
if complete?
|
29
|
+
puts "PUZZLE IS COMPLETE:"
|
30
|
+
|
31
|
+
puts
|
32
|
+
|
33
|
+
print_words
|
34
|
+
|
35
|
+
puts
|
36
|
+
return
|
37
|
+
end
|
38
|
+
|
39
|
+
print_answer
|
40
|
+
|
41
|
+
raise "Could not complete puzzle in #{num_attempts} (max attempts: #{MAX_LOOP_ATTEMPTS})"
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
|
46
|
+
def do_loop
|
47
|
+
@coded_words.each_with_index do |coded_word|
|
48
|
+
next if coded_word.all? { |num| number_assigned? num }
|
49
|
+
|
50
|
+
regexp = to_regexp(coded_word)
|
51
|
+
possibilities = @dictionary.find_by_regexp(regexp)
|
52
|
+
# this is a bit of a leak/hack; at this point, we're going a bit beyond regex's capabilities
|
53
|
+
possibilities = filter_invalid_possibilities(possibilities, coded_word)
|
54
|
+
|
55
|
+
# puts(
|
56
|
+
# "Found #{possibilities.length > 10 ? possibilities.length : possibilities.join(", ")} for #{coded_word} (regexp: /#{regexp}/)"
|
57
|
+
# )
|
58
|
+
|
59
|
+
assign_word(coded_word, possibilities[0]) if possibilities.length == 1
|
60
|
+
|
61
|
+
if possibilities.length <= 10 && !possibilities.empty?
|
62
|
+
# if the same letter appears in the same position for all of the possibilities, then it's
|
63
|
+
# safe to assign it
|
64
|
+
possibilities[0].chars.each_with_index do |char, index|
|
65
|
+
if possibilities.all? { |word| word[index] == char } && !number_assigned?(coded_word[index])
|
66
|
+
assign_letter(coded_word[index], char)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
break if complete?
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def assign_word(coded_word, word)
|
76
|
+
coded_word.each_with_index do |num, index|
|
77
|
+
next if number_assigned?(num)
|
78
|
+
|
79
|
+
assign_letter num, word[index]
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def number_assigned?(number)
|
84
|
+
!@letters_by_number[number].nil?
|
85
|
+
end
|
86
|
+
|
87
|
+
def assign_letter(number, letter)
|
88
|
+
raise if number.nil?
|
89
|
+
|
90
|
+
existing_num = @letters_by_number.invert[letter]
|
91
|
+
|
92
|
+
if existing_num && existing_num != number
|
93
|
+
raise "#{letter} cannot be assigned to #{number} - it's already assigned to #{existing_num}"
|
94
|
+
end
|
95
|
+
|
96
|
+
puts "Assigning #{letter} to #{number}"
|
97
|
+
|
98
|
+
@letters_by_number[number] ||= letter
|
99
|
+
end
|
100
|
+
|
101
|
+
def complete?
|
102
|
+
@letters_by_number.each_value.all? { |letter| !letter.nil? }
|
103
|
+
end
|
104
|
+
|
105
|
+
def filter_invalid_possibilities(possibilities, coded_word)
|
106
|
+
possibilities.filter { |poss| poss.chars.uniq.length == coded_word.uniq.length }
|
107
|
+
end
|
108
|
+
|
109
|
+
def print_answer
|
110
|
+
@letters_by_number.each do |num, letter|
|
111
|
+
puts "#{num.to_s.rjust(2, "0")} ==> #{letter}"
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
def print_words
|
116
|
+
words = @coded_words.map do |code_word|
|
117
|
+
code_word.each_with_object("") do |number, word|
|
118
|
+
word << @letters_by_number[number]
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
max_word_length = words.max_by(&:length).length
|
123
|
+
|
124
|
+
words.each_with_index do |word, i|
|
125
|
+
puts "#{word.rjust(max_word_length, " ")} ===> #{@coded_words[i]}"
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
def to_regexp(coded_word)
|
130
|
+
backreferences_for_unknown_numbers = []
|
131
|
+
|
132
|
+
unassigned_letters = ("A".."Z").to_a.join("").tr @letters_by_number.values.join(""), ""
|
133
|
+
|
134
|
+
regexp_chars = coded_word.map do |number|
|
135
|
+
letter = @letters_by_number[number]
|
136
|
+
next letter if letter
|
137
|
+
|
138
|
+
backreference_num = backreferences_for_unknown_numbers.index(number)
|
139
|
+
|
140
|
+
if backreference_num.nil?
|
141
|
+
backreferences_for_unknown_numbers << number
|
142
|
+
"(#{unassigned_letters.split("").join("|")})"
|
143
|
+
else
|
144
|
+
"\\#{backreference_num + 1}"
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
/\A#{regexp_chars.join("")}\z/
|
149
|
+
end
|
150
|
+
end
|