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 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