codewords_solver 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|