codewords_solver 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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
@@ -0,0 +1,8 @@
1
+ source "https://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in quince.gemspec
4
+ gemspec
5
+
6
+ gem "rake", "~> 13.0"
7
+
8
+ gem "rspec", "~> 3.0"
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,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task default: :spec
@@ -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,3 @@
1
+ class CodewordsSolver
2
+ VERSION = "0.0.1".freeze
3
+ 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