active_sanction 1.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.
Files changed (143) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +538 -0
  3. data/CODE_OF_CONDUCT.md +74 -0
  4. data/CONTRIBUTING.md +312 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +616 -0
  7. data/SECURITY.md +97 -0
  8. data/active_sanction.gemspec +98 -0
  9. data/docs/adding_a_source.md +1083 -0
  10. data/docs/api_stability.md +357 -0
  11. data/docs/bundle_format.md +407 -0
  12. data/lib/active_sanction/address.rb +170 -0
  13. data/lib/active_sanction/client.rb +341 -0
  14. data/lib/active_sanction/configuration.rb +721 -0
  15. data/lib/active_sanction/countries.txt +292 -0
  16. data/lib/active_sanction/country.rb +164 -0
  17. data/lib/active_sanction/deprecation.rb +168 -0
  18. data/lib/active_sanction/diff/change.rb +208 -0
  19. data/lib/active_sanction/diff.rb +348 -0
  20. data/lib/active_sanction/doctor/checkup.rb +356 -0
  21. data/lib/active_sanction/doctor/diagnosis.rb +245 -0
  22. data/lib/active_sanction/doctor/finding.rb +183 -0
  23. data/lib/active_sanction/doctor/profile.rb +384 -0
  24. data/lib/active_sanction/doctor/report.rb +227 -0
  25. data/lib/active_sanction/doctor.rb +347 -0
  26. data/lib/active_sanction/entity.rb +293 -0
  27. data/lib/active_sanction/error.rb +332 -0
  28. data/lib/active_sanction/fetcher/result.rb +116 -0
  29. data/lib/active_sanction/fetcher.rb +242 -0
  30. data/lib/active_sanction/http_client/errors.rb +94 -0
  31. data/lib/active_sanction/http_client/response.rb +129 -0
  32. data/lib/active_sanction/http_client.rb +324 -0
  33. data/lib/active_sanction/identifier.rb +212 -0
  34. data/lib/active_sanction/index/builder.rb +89 -0
  35. data/lib/active_sanction/index/candidate.rb +63 -0
  36. data/lib/active_sanction/index/entry.rb +66 -0
  37. data/lib/active_sanction/index/features.rb +112 -0
  38. data/lib/active_sanction/index.rb +381 -0
  39. data/lib/active_sanction/match_result.rb +345 -0
  40. data/lib/active_sanction/matcher.rb +388 -0
  41. data/lib/active_sanction/name.rb +183 -0
  42. data/lib/active_sanction/normalizer/cache.rb +82 -0
  43. data/lib/active_sanction/normalizer/dictionaries/honorifics.txt +30 -0
  44. data/lib/active_sanction/normalizer/dictionaries/legal_forms.txt +72 -0
  45. data/lib/active_sanction/normalizer/dictionaries/organization_stopwords.txt +20 -0
  46. data/lib/active_sanction/normalizer/dictionaries/particles.txt +35 -0
  47. data/lib/active_sanction/normalizer/dictionary/stoplist.rb +121 -0
  48. data/lib/active_sanction/normalizer/dictionary.rb +243 -0
  49. data/lib/active_sanction/normalizer/form.rb +279 -0
  50. data/lib/active_sanction/normalizer.rb +137 -0
  51. data/lib/active_sanction/parsers/column_shape.rb +274 -0
  52. data/lib/active_sanction/parsers/delimited_table/reader.rb +194 -0
  53. data/lib/active_sanction/parsers/delimited_table/row.rb +81 -0
  54. data/lib/active_sanction/parsers/delimited_table.rb +151 -0
  55. data/lib/active_sanction/parsers/format.rb +107 -0
  56. data/lib/active_sanction/parsers/join.rb +149 -0
  57. data/lib/active_sanction/parsers/spreadsheet/archive.rb +258 -0
  58. data/lib/active_sanction/parsers/spreadsheet/reader.rb +213 -0
  59. data/lib/active_sanction/parsers/spreadsheet/row.rb +88 -0
  60. data/lib/active_sanction/parsers/spreadsheet/workbook.rb +314 -0
  61. data/lib/active_sanction/parsers/spreadsheet.rb +168 -0
  62. data/lib/active_sanction/parsers/xml_records/backends/nokogiri.rb +134 -0
  63. data/lib/active_sanction/parsers/xml_records/backends/rexml.rb +157 -0
  64. data/lib/active_sanction/parsers/xml_records/backends.rb +103 -0
  65. data/lib/active_sanction/parsers/xml_records/builder.rb +90 -0
  66. data/lib/active_sanction/parsers/xml_records/reader.rb +134 -0
  67. data/lib/active_sanction/parsers/xml_records/record.rb +174 -0
  68. data/lib/active_sanction/parsers/xml_records.rb +140 -0
  69. data/lib/active_sanction/parsers.rb +94 -0
  70. data/lib/active_sanction/partial_date/parser.rb +124 -0
  71. data/lib/active_sanction/partial_date.rb +303 -0
  72. data/lib/active_sanction/payload_cache/checksum.rb +66 -0
  73. data/lib/active_sanction/payload_cache/entry.rb +304 -0
  74. data/lib/active_sanction/payload_cache.rb +366 -0
  75. data/lib/active_sanction/phonetics/double_metaphone.rb +662 -0
  76. data/lib/active_sanction/phonetics.rb +40 -0
  77. data/lib/active_sanction/query.rb +290 -0
  78. data/lib/active_sanction/rescreen/alert.rb +314 -0
  79. data/lib/active_sanction/rescreen.rb +332 -0
  80. data/lib/active_sanction/scorer/adjustments.rb +241 -0
  81. data/lib/active_sanction/scorer/name_score.rb +279 -0
  82. data/lib/active_sanction/scorer/reason.rb +136 -0
  83. data/lib/active_sanction/scorer/result.rb +129 -0
  84. data/lib/active_sanction/scorer/subject.rb +235 -0
  85. data/lib/active_sanction/scorer/weights.rb +306 -0
  86. data/lib/active_sanction/scorer.rb +303 -0
  87. data/lib/active_sanction/similarity/jaro_winkler.rb +210 -0
  88. data/lib/active_sanction/similarity/levenshtein.rb +179 -0
  89. data/lib/active_sanction/similarity/token_set.rb +159 -0
  90. data/lib/active_sanction/similarity/token_sort.rb +120 -0
  91. data/lib/active_sanction/similarity.rb +183 -0
  92. data/lib/active_sanction/snapshot/bundle/header.rb +302 -0
  93. data/lib/active_sanction/snapshot/bundle/payload.rb +185 -0
  94. data/lib/active_sanction/snapshot/bundle/signature.rb +201 -0
  95. data/lib/active_sanction/snapshot/bundle.rb +381 -0
  96. data/lib/active_sanction/snapshot.rb +305 -0
  97. data/lib/active_sanction/sources/australia_dfat/published_date.rb +166 -0
  98. data/lib/active_sanction/sources/australia_dfat/record.rb +319 -0
  99. data/lib/active_sanction/sources/australia_dfat.rb +291 -0
  100. data/lib/active_sanction/sources/base.rb +295 -0
  101. data/lib/active_sanction/sources/canada_sema/record.rb +282 -0
  102. data/lib/active_sanction/sources/canada_sema/source_ref.rb +89 -0
  103. data/lib/active_sanction/sources/canada_sema.rb +153 -0
  104. data/lib/active_sanction/sources/definition.rb +360 -0
  105. data/lib/active_sanction/sources/eu_fsf/record.rb +467 -0
  106. data/lib/active_sanction/sources/eu_fsf.rb +203 -0
  107. data/lib/active_sanction/sources/ofac/record.rb +251 -0
  108. data/lib/active_sanction/sources/ofac/remarks_parser/coverage.rb +120 -0
  109. data/lib/active_sanction/sources/ofac/remarks_parser/vocabulary.rb +121 -0
  110. data/lib/active_sanction/sources/ofac/remarks_parser.rb +312 -0
  111. data/lib/active_sanction/sources/ofac.rb +248 -0
  112. data/lib/active_sanction/sources/ofac_consolidated/record.rb +37 -0
  113. data/lib/active_sanction/sources/ofac_consolidated.rb +203 -0
  114. data/lib/active_sanction/sources/ofac_sdn.rb +43 -0
  115. data/lib/active_sanction/sources/remarks.rb +84 -0
  116. data/lib/active_sanction/sources/uk_sanctions_list/published_date.rb +104 -0
  117. data/lib/active_sanction/sources/uk_sanctions_list/record.rb +412 -0
  118. data/lib/active_sanction/sources/uk_sanctions_list.rb +214 -0
  119. data/lib/active_sanction/sources/un_consolidated/record.rb +279 -0
  120. data/lib/active_sanction/sources/un_consolidated.rb +140 -0
  121. data/lib/active_sanction/sources.rb +196 -0
  122. data/lib/active_sanction/storage/active_record/reader.rb +124 -0
  123. data/lib/active_sanction/storage/active_record/row.rb +175 -0
  124. data/lib/active_sanction/storage/active_record/writer.rb +141 -0
  125. data/lib/active_sanction/storage/active_record.rb +277 -0
  126. data/lib/active_sanction/storage/base.rb +216 -0
  127. data/lib/active_sanction/storage/file_system.rb +373 -0
  128. data/lib/active_sanction/storage/memory.rb +67 -0
  129. data/lib/active_sanction/storage/meta.rb +188 -0
  130. data/lib/active_sanction/storage.rb +74 -0
  131. data/lib/active_sanction/subject.rb +271 -0
  132. data/lib/active_sanction/sync/report.rb +245 -0
  133. data/lib/active_sanction/sync/result.rb +296 -0
  134. data/lib/active_sanction/sync.rb +390 -0
  135. data/lib/active_sanction/validator_store/file_system.rb +103 -0
  136. data/lib/active_sanction/validator_store/memory.rb +41 -0
  137. data/lib/active_sanction/validator_store.rb +119 -0
  138. data/lib/active_sanction/validators.rb +195 -0
  139. data/lib/active_sanction/version.rb +23 -0
  140. data/lib/active_sanction.rb +331 -0
  141. data/lib/generators/active_sanction/install/install_generator.rb +60 -0
  142. data/lib/generators/active_sanction/install/templates/create_active_sanction_tables.rb.tt +142 -0
  143. metadata +236 -0
@@ -0,0 +1,662 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module ActiveSanction
7
+ module Phonetics
8
+ # Double Metaphone, as Lawrence Philips published it. Stage 3c of the
9
+ # matching pipeline.
10
+ #
11
+ # ActiveSanction::Phonetics::DoubleMetaphone.call("mohammed") # => ["MHMT"]
12
+ # ActiveSanction::Phonetics::DoubleMetaphone.call("muhammad") # => ["MHMT"]
13
+ # ActiveSanction::Phonetics::DoubleMetaphone.call("jusuf") # => ["JSF", "ASF"]
14
+ # ActiveSanction::Phonetics::DoubleMetaphone.call("yusuf") # => ["ASF"]
15
+ #
16
+ # DoubleMetaphone.match?("qaddafi", "gaddafi") # => true
17
+ #
18
+ # ### One word, not one name
19
+ #
20
+ # Every method here takes a single folded token. A name is keyed token by
21
+ # token, which is what the index (#31) needs -- a bucket per token is what
22
+ # makes a query's tokens findable -- and it is the reason the algorithm's
23
+ # multi-word rules are not exercised here. Philips' table has rules keyed
24
+ # on `VAN `, `VON `, `SAN ` and `MAC C`, and they need the following space
25
+ # to fire; a token handed over on its own never has one. The trade is
26
+ # deliberate: those rules cover a handful of surname prefixes, and keying
27
+ # per token is what the whole stage is for.
28
+ #
29
+ # ### What the two keys are
30
+ #
31
+ # Philips returns a primary and an alternate, and the alternate is the
32
+ # point. It is not a fallback or a fuzzier version of the same key: it is
33
+ # the second way the word is pronounced when a spelling is genuinely
34
+ # ambiguous about its language of origin. `JUSUF` is `JSF` read as
35
+ # English and `ASF` read as Slavic, and `YUSUF` is `ASF` outright. Two
36
+ # names match phonetically when *any* of their keys agree, which is what
37
+ # `match?` does and what an index has to do -- comparing primaries alone
38
+ # would miss exactly the transliteration pairs this stage exists for.
39
+ #
40
+ # `call` returns one key or two: the alternate is dropped when it repeats
41
+ # the primary, so a caller indexing every key never writes the same bucket
42
+ # twice.
43
+ #
44
+ # ### Keys are not truncated
45
+ #
46
+ # Philips' 1990 Metaphone truncated to four characters and the article
47
+ # version of Double Metaphone kept that; the reference C++ raised the cap
48
+ # to 32, which no name reaches. Nothing is truncated here. A caller who
49
+ # wants the classic four-character key takes `key[0, 4]` -- the keys are
50
+ # built left to right, so a prefix of the full key is exactly what a
51
+ # truncating implementation would have produced.
52
+ #
53
+ # The choice matters to the index and not much else: four characters put
54
+ # `GAZPROM` and `GAZPROMBANK` in one bucket, which is good recall and
55
+ # poor precision, and #31 can decide that for itself. Throwing the
56
+ # characters away here would not leave it the choice.
57
+ #
58
+ # ### Latin script only, and it is a mitigation rather than a fix
59
+ #
60
+ # This is a table of English, Germanic, Slavic, Romance and Greek
61
+ # spelling conventions written in the Latin alphabet. Handed anything
62
+ # else it produces no key at all: every character that is not an ASCII
63
+ # letter is skipped, so `путин` and `محمد` come back as `[]` and are not
64
+ # phonetically indexable. That is the same recall limitation Normalizer
65
+ # states about non-Latin script, in the same place in the pipeline, and
66
+ # for the same reason -- these lists publish a romanized name alongside
67
+ # the original, and the romanized one is what an English-language query
68
+ # finds.
69
+ #
70
+ # Within Latin script it is a mitigation for transliteration variance and
71
+ # not a substitute for real transliteration. It collapses `MOHAMMED`,
72
+ # `MUHAMMAD`, `MOHAMAD` and `MOHAMED` onto `MHMT`, and `QADDAFI`,
73
+ # `GADDAFI`, `KADAFI` and `QADHAFI` onto `KTF`, which is the variance
74
+ # these lists actually contain. It also collapses names that are not the
75
+ # same name at all: `HUSSEIN`, `HASSAN` and `HASAN` are one key, and so
76
+ # are `PUTIN`, `PATTON` and `BUTTON`. So a phonetic agreement is a signal
77
+ # the scorer (#32) weighs, never a match on its own. It is deliberately
78
+ # generous, in a pipeline whose other columns are not.
79
+ #
80
+ # And it misses things the other columns catch. `ZAWAHIRI` keys as `SHR`
81
+ # and `ZAWAHRI` as `SR`, because dropping the `I` moves the `H` to where
82
+ # the rules make it silent -- a pair Levenshtein scores 0.85. Neither
83
+ # direction is a defect to be tuned out. Four columns exist because each
84
+ # of them is wrong somewhere the others are right.
85
+ #
86
+ # ### What it costs, and where
87
+ #
88
+ # About 30 us a word without a JIT and 17 with one, which is a whole index
89
+ # build's worth of keys -- 46,000 names, call it 138,000 tokens -- in
90
+ # around four seconds, and a three-token query's worth in under a tenth of
91
+ # a millisecond. The cost lands on the build rather than on the query,
92
+ # which is the right end for it: a key is written once per sync and read
93
+ # on every screening call after it.
94
+ #
95
+ # Most of that is the string slicing the rules are written in, and it
96
+ # stays. Comparing without allocating would mean rewriting a hundred
97
+ # published rules into index arithmetic nobody can check against the
98
+ # source, to save three seconds of a job that runs when a government
99
+ # publishes a file.
100
+ #
101
+ # ### Transcribed, not reimplemented
102
+ #
103
+ # The rules below are a transcription of Philips' reference C++ -- the
104
+ # 2000 C/C++ Users Journal algorithm, in the widely mirrored
105
+ # `double_metaphone.cc` -- kept in its order, with its section comments,
106
+ # so that the two can be read side by side. That is the only way anybody
107
+ # can check this file: the rules are a hundred-odd special cases about
108
+ # spelling that cannot be derived from anything, only compared against
109
+ # the source they came from. The specs compare the output against that
110
+ # implementation's own, over a corpus of several thousand words.
111
+ #
112
+ # Three things in that transcription are deliberate departures, all of
113
+ # them faithful to the C rather than to the ports that are easier to find:
114
+ #
115
+ # 1. A character outside `A-Z` advances one position and contributes
116
+ # nothing, which is the C's `default:`. Several widely used ports
117
+ # instead re-apply the previous character's rule, so `putin2` keys as
118
+ # `PTNN`. Folded names carry digits.
119
+ # 2. The word is padded with spaces, as the C pads it. Rules that look
120
+ # for a trailing space -- the French `IER ` ending among them -- fire
121
+ # at the end of a word, which is where they were meant to fire.
122
+ # 3. `-UMB` is read from the character before the `M`, so `dumb` keys as
123
+ # `TM` rather than `TMP`.
124
+ class DoubleMetaphone
125
+ extend T::Sig
126
+
127
+ # What one letter's rule decides: what to append to each key, and how
128
+ # far to move. An empty string appends nothing, which is how the C
129
+ # writes a code that exists on one side only (`primary += ""`).
130
+ Step = T.type_alias { [String, String, Integer] }
131
+
132
+ # `Y` is a vowel here. It is in Philips' `IsVowel`, and dropping it
133
+ # would change the initial-vowel rule that `YUSUF` depends on.
134
+ VOWELS = T.let(%w[A E I O U Y].freeze, T::Array[String])
135
+
136
+ # The first letter of these pairs is not pronounced: `KNIGHT`, `WRIGHT`,
137
+ # `PSALM`.
138
+ SILENT_STARTERS = T.let(%w[GN KN PN WR PS].freeze, T::Array[String])
139
+
140
+ # Five, as the reference pads, and semantic rather than defensive --
141
+ # see the class notes. Every lookahead in the table stays inside it.
142
+ PADDING = T.let(" ", String)
143
+
144
+ # A word's phonetic keys: one, or two when the spelling is ambiguous,
145
+ # and none at all when it holds no Latin letters.
146
+ sig { params(word: String).returns(T::Array[String]).checked(:tests) }
147
+ def self.call(word) = new(word).keys
148
+
149
+ # Whether two words agree on any key. The cross comparisons are the
150
+ # reason this is a method rather than an `==`: `JUSUF` and `YUSUF` agree
151
+ # on `JUSUF`'s alternate, and a caller comparing primaries would miss it.
152
+ #
153
+ # A word with no keys agrees with nothing, itself included. Two names
154
+ # written in a script this table cannot read are not evidence of
155
+ # anything, and an empty key that matched every other empty key would
156
+ # make every Cyrillic name a phonetic hit against every other one.
157
+ sig { params(left: String, right: String).returns(T::Boolean).checked(:tests) }
158
+ def self.match?(left, right) = call(left).intersect?(call(right))
159
+
160
+ # The primary key, or an empty string for a word with no Latin letters.
161
+ sig { returns(String).checked(:tests) }
162
+ attr_reader :primary
163
+
164
+ # The second pronunciation, or nil when the word has only one. Nil
165
+ # rather than a repeat of the primary, so that "this word is ambiguous"
166
+ # and "this word is not" are different answers.
167
+ sig { returns(T.nilable(String)).checked(:tests) }
168
+ attr_reader :alternate
169
+
170
+ # Every distinct non-empty key, primary first. This is what an index
171
+ # writes and what `match?` intersects.
172
+ sig { returns(T::Array[String]).checked(:tests) }
173
+ attr_reader :keys
174
+
175
+ # Encoding happens here, once, and the result is frozen: a key is a
176
+ # pure function of the word, and the same word is keyed thousands of
177
+ # times during an index build.
178
+ #
179
+ # Uppercasing is not a fold. The rule table is written in capitals and
180
+ # this is the alphabet it reads; the fold that decides what a name *is*
181
+ # happened once already, in Normalizer, for both sides of every
182
+ # comparison.
183
+ sig { params(word: String).void.checked(:tests) }
184
+ def initialize(word)
185
+ upper = word.to_s.upcase
186
+ @buffer = T.let("#{upper}#{PADDING}", String)
187
+ @last = T.let(upper.length - 1, Integer)
188
+ @slavo_germanic = T.let(slavo_germanic?(upper), T::Boolean)
189
+ @position = T.let(0, Integer)
190
+ @primary = T.let(+"", String)
191
+ @secondary = T.let(+"", String)
192
+ encode
193
+ @primary = -@primary
194
+ @secondary = -@secondary
195
+ @alternate = T.let(@secondary == @primary ? nil : presence(@secondary), T.nilable(String))
196
+ @keys = T.let([@primary, @secondary].reject(&:empty?).uniq.freeze, T::Array[String])
197
+ freeze
198
+ end
199
+
200
+ sig { returns(String) }
201
+ def inspect = "#<#{self.class} #{@keys.inspect}>"
202
+
203
+ private
204
+
205
+ # The main loop. Philips' runs until both keys reach a length cap; this
206
+ # one runs to the end of the word, for the reason the class notes give.
207
+ sig { void }
208
+ def encode
209
+ skip_silent_start
210
+ while @position <= @last
211
+ primary, secondary, advance = rule
212
+ @primary << primary
213
+ @secondary << secondary
214
+ @position += advance
215
+ end
216
+ end
217
+
218
+ # The two things that happen before the first letter is read.
219
+ sig { void }
220
+ def skip_silent_start
221
+ @position += 1 if SILENT_STARTERS.include?(span(0, 2))
222
+ return unless at(0) == "X" # initial 'X' is pronounced 'Z', e.g. 'Xavier'
223
+
224
+ @primary << "S"
225
+ @secondary << "S"
226
+ @position += 1
227
+ end
228
+
229
+ # `W`, `K`, `CZ` or `WITZ` anywhere in the word. Philips uses this to
230
+ # decide whether a spelling is Slavic or Germanic, which changes what
231
+ # `G`, `J`, `S` and `Z` are worth.
232
+ sig { params(word: String).returns(T::Boolean) }
233
+ def slavo_germanic?(word)
234
+ word.include?("W") || word.include?("K") || word.include?("CZ") || word.include?("WITZ")
235
+ end
236
+
237
+ # One character of the buffer, or "" past either end. Philips' `GetAt`
238
+ # returns a NUL there and his `StringAt` refuses a start past the end;
239
+ # both compare false against every rule, which is what "" does here.
240
+ sig { params(index: Integer).returns(String) }
241
+ def at(index) = index.negative? ? "" : @buffer[index].to_s
242
+
243
+ sig { params(index: Integer, length: Integer).returns(String) }
244
+ def span(index, length) = index.negative? ? "" : @buffer[index, length].to_s
245
+
246
+ sig { params(index: Integer).returns(T::Boolean) }
247
+ def vowel?(index) = VOWELS.include?(at(index))
248
+
249
+ sig { params(string: String).returns(T.nilable(String)) }
250
+ def presence(string) = string.empty? ? nil : string
251
+
252
+ # The same code on both keys.
253
+ sig { params(code: String, advance: Integer).returns(Step) }
254
+ def both(code, advance) = [code, code, advance]
255
+
256
+ # A different code on each -- the ambiguity the second key exists for.
257
+ sig { params(primary: String, secondary: String, advance: Integer).returns(Step) }
258
+ def either(primary, secondary, advance) = [primary, secondary, advance]
259
+
260
+ # Move on and say nothing: a silent letter, a space, a digit, or a
261
+ # letter this table cannot read.
262
+ sig { params(advance: Integer).returns(Step) }
263
+ def silent(advance) = ["", "", advance]
264
+
265
+ sig { returns(Step) }
266
+ def rule
267
+ case at(@position)
268
+ when "A", "E", "I", "O", "U", "Y" then @position.zero? ? both("A", 1) : silent(1)
269
+ when "B" then both("P", at(@position + 1) == "B" ? 2 : 1)
270
+ when "C" then letter_c
271
+ when "D" then letter_d
272
+ when "F" then both("F", at(@position + 1) == "F" ? 2 : 1)
273
+ when "G" then letter_g
274
+ when "H" then letter_h
275
+ when "J" then letter_j
276
+ when "K" then both("K", at(@position + 1) == "K" ? 2 : 1)
277
+ when "L" then letter_l
278
+ when "M" then letter_m
279
+ when "N" then both("N", at(@position + 1) == "N" ? 2 : 1)
280
+ when "P" then letter_p
281
+ when "Q" then both("K", at(@position + 1) == "Q" ? 2 : 1)
282
+ when "R" then letter_r
283
+ when "S" then letter_s
284
+ when "T" then letter_t
285
+ when "V" then both("F", at(@position + 1) == "V" ? 2 : 1)
286
+ when "W" then letter_w
287
+ when "X" then letter_x
288
+ when "Z" then letter_z
289
+ else silent(1)
290
+ end
291
+ end
292
+
293
+ # 'C' -- around a hundred contexts, which is what the algorithm is
294
+ # famous for.
295
+ sig { returns(Step) }
296
+ def letter_c
297
+ return both("K", 2) if germanic_ach?
298
+ return both("S", 2) if @position.zero? && span(@position, 6) == "CAESAR" # 'caesar'
299
+ return both("K", 2) if span(@position, 4) == "CHIA" # italian 'chianti'
300
+ return letter_ch if span(@position, 2) == "CH"
301
+ return either("S", "X", 2) if span(@position, 2) == "CZ" && span(@position - 2, 4) != "WICZ" # 'czerny'
302
+ return both("X", 3) if span(@position + 1, 3) == "CIA" # 'focaccia'
303
+ return letter_cc if span(@position, 2) == "CC" && !(@position == 1 && at(0) == "M") # not 'McClellan'
304
+ return both("K", 2) if %w[CK CG CQ].include?(span(@position, 2))
305
+ return letter_ci if %w[CI CE CY].include?(span(@position, 2))
306
+
307
+ both("K", trailing_c_advance)
308
+ end
309
+
310
+ # Various germanic, e.g. 'bacher', 'macher'.
311
+ sig { returns(T::Boolean) }
312
+ def germanic_ach?
313
+ @position > 1 && !vowel?(@position - 2) && span(@position - 1, 3) == "ACH" &&
314
+ at(@position + 2) != "I" &&
315
+ (at(@position + 2) != "E" || %w[BACHER MACHER].include?(span(@position - 2, 6)))
316
+ end
317
+
318
+ sig { returns(Step) }
319
+ def letter_ch
320
+ return either("K", "X", 2) if @position.positive? && span(@position, 4) == "CHAE" # find 'michael'
321
+ return both("K", 2) if greek_ch? || germanic_ch?
322
+ return both("X", 2) if @position.zero?
323
+ return both("K", 2) if span(0, 2) == "MC" # e.g. 'McHugh'
324
+
325
+ either("X", "K", 2)
326
+ end
327
+
328
+ # Greek roots, e.g. 'chemistry', 'chorus'.
329
+ sig { returns(T::Boolean) }
330
+ def greek_ch?
331
+ @position.zero? &&
332
+ (%w[HARAC HARIS].include?(span(@position + 1, 5)) ||
333
+ %w[HOR HYM HIA HEM].include?(span(@position + 1, 3))) &&
334
+ span(0, 5) != "CHORE"
335
+ end
336
+
337
+ # Germanic, greek, or otherwise 'ch' for 'kh' sound.
338
+ sig { returns(T::Boolean) }
339
+ def germanic_ch?
340
+ ["VAN ", "VON "].include?(span(0, 4)) || span(0, 3) == "SCH" ||
341
+ # 'architect' but not 'arch', 'orchestra', 'orchid'
342
+ %w[ORCHES ARCHIT ORCHID].include?(span(@position - 2, 6)) ||
343
+ %w[T S].include?(at(@position + 2)) ||
344
+ ((%w[A O U E].include?(at(@position - 1)) || @position.zero?) &&
345
+ # e.g. 'wachtler', 'wechsler', but not 'tichner'
346
+ ["L", "R", "N", "M", "B", "H", "F", "V", "W", " "].include?(at(@position + 2)))
347
+ end
348
+
349
+ # Double 'C'.
350
+ sig { returns(Step) }
351
+ def letter_cc
352
+ # 'bellocchio' but not 'bacchus'
353
+ return both("K", 2) unless %w[I E H].include?(at(@position + 2)) && span(@position + 2, 2) != "HU"
354
+ # 'accident', 'accede', 'succeed'
355
+ return both("KS", 3) if (@position == 1 && at(0) == "A") || %w[UCCEE UCCES].include?(span(@position - 1, 5))
356
+
357
+ both("X", 3) # 'bacci', 'bertucci', other italian
358
+ end
359
+
360
+ sig { returns(Step) }
361
+ def letter_ci
362
+ # italian vs. english
363
+ return either("S", "X", 2) if %w[CIO CIE CIA].include?(span(@position, 3))
364
+
365
+ both("S", 2)
366
+ end
367
+
368
+ sig { returns(Integer) }
369
+ def trailing_c_advance
370
+ return 3 if [" C", " Q", " G"].include?(span(@position + 1, 2)) # 'mac caffrey', 'mac gregor'
371
+ return 2 if %w[C K Q].include?(at(@position + 1)) && !%w[CE CI].include?(span(@position + 1, 2))
372
+
373
+ 1
374
+ end
375
+
376
+ sig { returns(Step) }
377
+ def letter_d
378
+ if span(@position, 2) == "DG"
379
+ return %w[I E Y].include?(at(@position + 2)) ? both("J", 3) : both("TK", 2) # 'edge' / 'edgar'
380
+ end
381
+ return both("T", 2) if %w[DT DD].include?(span(@position, 2))
382
+
383
+ both("T", 1)
384
+ end
385
+
386
+ sig { returns(Step) }
387
+ def letter_g
388
+ return letter_gh if at(@position + 1) == "H"
389
+ return letter_gn if at(@position + 1) == "N"
390
+ return either("KL", "L", 2) if span(@position + 1, 2) == "LI" && !@slavo_germanic # 'tagliaro'
391
+ return either("K", "J", 2) if initial_soft_g? || soft_ger?
392
+ return italian_g if %w[E I Y].include?(at(@position + 1)) || %w[AGGI OGGI].include?(span(@position - 1, 4))
393
+
394
+ both("K", at(@position + 1) == "G" ? 2 : 1)
395
+ end
396
+
397
+ sig { returns(Step) }
398
+ def letter_gh
399
+ return both("K", 2) if @position.positive? && !vowel?(@position - 1)
400
+ # 'ghislane', 'ghiradelli'
401
+ return both(at(@position + 2) == "I" ? "J" : "K", 2) if @position.zero?
402
+ # Parker's rule (with some further refinements), e.g. 'hugh'
403
+ return silent(2) if parkers_rule?
404
+ # e.g. 'laugh', 'McLaughlin', 'cough', 'gough', 'rough', 'tough'
405
+ return both("F", 2) if @position > 2 && at(@position - 1) == "U" && %w[C G L R T].include?(at(@position - 3))
406
+ return both("K", 2) if @position.positive? && at(@position - 1) != "I"
407
+
408
+ silent(2)
409
+ end
410
+
411
+ sig { returns(T::Boolean) }
412
+ def parkers_rule?
413
+ (@position > 1 && %w[B H D].include?(at(@position - 2))) ||
414
+ (@position > 2 && %w[B H D].include?(at(@position - 3))) || # e.g. 'bough'
415
+ (@position > 3 && %w[B H].include?(at(@position - 4))) # e.g. 'broughton'
416
+ end
417
+
418
+ sig { returns(Step) }
419
+ def letter_gn
420
+ return either("KN", "N", 2) if @position == 1 && vowel?(0) && !@slavo_germanic
421
+ # not e.g. 'cagney'
422
+ return either("N", "KN", 2) if span(@position + 2, 2) != "EY" && at(@position + 1) != "Y" && !@slavo_germanic
423
+
424
+ both("KN", 2)
425
+ end
426
+
427
+ # -ges-, -gep-, -gel-, -gie- at beginning.
428
+ sig { returns(T::Boolean) }
429
+ def initial_soft_g?
430
+ @position.zero? &&
431
+ (at(@position + 1) == "Y" ||
432
+ %w[ES EP EB EL EY IB IL IN IE EI ER].include?(span(@position + 1, 2)))
433
+ end
434
+
435
+ # -ger-, -gy-.
436
+ sig { returns(T::Boolean) }
437
+ def soft_ger?
438
+ (span(@position + 1, 2) == "ER" || at(@position + 1) == "Y") &&
439
+ !%w[DANGER RANGER MANGER].include?(span(0, 6)) &&
440
+ !%w[E I].include?(at(@position - 1)) &&
441
+ !%w[RGY OGY].include?(span(@position - 1, 3))
442
+ end
443
+
444
+ # Italian, e.g. 'biaggi'.
445
+ sig { returns(Step) }
446
+ def italian_g
447
+ # obvious germanic
448
+ return both("K", 2) if ["VAN ", "VON "].include?(span(0, 4)) || span(0, 3) == "SCH" ||
449
+ span(@position + 1, 2) == "ET"
450
+ # always soft if french ending
451
+ return both("J", 2) if span(@position + 1, 4) == "IER "
452
+
453
+ either("J", "K", 2)
454
+ end
455
+
456
+ # Only keep 'H' first, or between two vowels. Also takes care of 'HH'.
457
+ sig { returns(Step) }
458
+ def letter_h
459
+ return both("H", 2) if (@position.zero? || vowel?(@position - 1)) && vowel?(@position + 1)
460
+
461
+ silent(1)
462
+ end
463
+
464
+ sig { returns(Step) }
465
+ def letter_j
466
+ return spanish_j if span(@position, 4) == "JOSE" || span(0, 4) == "SAN " # 'jose', 'san jacinto'
467
+
468
+ advance = at(@position + 1) == "J" ? 2 : 1 # it could happen!
469
+ return either("J", "A", advance) if @position.zero? # Yankelovich/Jankelowicz
470
+ # spanish pronunciation of e.g. 'bajador'
471
+ return either("J", "H", advance) if vowel?(@position - 1) && !@slavo_germanic &&
472
+ %w[A O].include?(at(@position + 1))
473
+ return either("J", "", advance) if @position == @last
474
+ return both("J", advance) if !%w[L T K S N M B Z].include?(at(@position + 1)) &&
475
+ !%w[S K L].include?(at(@position - 1))
476
+
477
+ silent(advance)
478
+ end
479
+
480
+ sig { returns(Step) }
481
+ def spanish_j
482
+ return both("H", 1) if (@position.zero? && at(@position + 4) == " ") || span(0, 4) == "SAN "
483
+
484
+ either("J", "H", 1)
485
+ end
486
+
487
+ sig { returns(Step) }
488
+ def letter_l
489
+ return both("L", 1) unless at(@position + 1) == "L"
490
+ # spanish e.g. 'cabrillo', 'gallegos'
491
+ return either("L", "", 2) if spanish_ll?
492
+
493
+ both("L", 2)
494
+ end
495
+
496
+ sig { returns(T::Boolean) }
497
+ def spanish_ll?
498
+ (@position == @last - 2 && %w[ILLO ILLA ALLE].include?(span(@position - 1, 4))) ||
499
+ ((%w[AS OS].include?(span(@last - 1, 2)) || %w[A O].include?(at(@last))) &&
500
+ span(@position - 1, 4) == "ALLE")
501
+ end
502
+
503
+ # 'dumb', 'thumb': the 'B' is silent, so the 'M' consumes it.
504
+ sig { returns(Step) }
505
+ def letter_m
506
+ umb = span(@position - 1, 3) == "UMB" && (@position + 1 == @last || span(@position + 2, 2) == "ER")
507
+
508
+ both("M", umb || at(@position + 1) == "M" ? 2 : 1)
509
+ end
510
+
511
+ sig { returns(Step) }
512
+ def letter_p
513
+ return both("F", 2) if at(@position + 1) == "H"
514
+
515
+ # also account for 'campbell', 'raspberry'
516
+ both("P", %w[P B].include?(at(@position + 1)) ? 2 : 1)
517
+ end
518
+
519
+ sig { returns(Step) }
520
+ def letter_r
521
+ advance = at(@position + 1) == "R" ? 2 : 1
522
+ # french e.g. 'rogier', but exclude 'hochmeier'
523
+ french = @position == @last && !@slavo_germanic &&
524
+ span(@position - 2, 2) == "IE" && !%w[ME MA].include?(span(@position - 4, 2))
525
+
526
+ french ? either("", "R", advance) : both("R", advance)
527
+ end
528
+
529
+ sig { returns(Step) }
530
+ def letter_s
531
+ return silent(1) if %w[ISL YSL].include?(span(@position - 1, 3)) # 'island', 'isle', 'carlisle'
532
+ return either("X", "S", 1) if @position.zero? && span(@position, 5) == "SUGAR"
533
+ return letter_sh if span(@position, 2) == "SH"
534
+ return italian_s if %w[SIO SIA].include?(span(@position, 3)) || span(@position, 4) == "SIAN"
535
+ # german & anglicisations: 'smith' matches 'schmidt', 'snider' matches 'schneider'
536
+ return either("S", "X", at(@position + 1) == "Z" ? 2 : 1) if germanic_s?
537
+ return letter_sc if span(@position, 2) == "SC"
538
+
539
+ trailing_s
540
+ end
541
+
542
+ sig { returns(Step) }
543
+ def letter_sh
544
+ # germanic
545
+ return both("S", 2) if %w[HEIM HOEK HOLM HOLZ].include?(span(@position + 1, 4))
546
+
547
+ both("X", 2)
548
+ end
549
+
550
+ # Italian & armenian.
551
+ sig { returns(Step) }
552
+ def italian_s
553
+ @slavo_germanic ? both("S", 3) : either("S", "X", 3)
554
+ end
555
+
556
+ sig { returns(T::Boolean) }
557
+ def germanic_s?
558
+ (@position.zero? && %w[M N L W].include?(at(@position + 1))) || at(@position + 1) == "Z"
559
+ end
560
+
561
+ sig { returns(Step) }
562
+ def letter_sc
563
+ return schlesingers_rule if at(@position + 2) == "H"
564
+ return both("S", 3) if %w[I E Y].include?(at(@position + 2))
565
+
566
+ both("SK", 3)
567
+ end
568
+
569
+ sig { returns(Step) }
570
+ def schlesingers_rule
571
+ # dutch origin, e.g. 'school', 'schooner'
572
+ if %w[OO ER EN UY ED EM].include?(span(@position + 3, 2))
573
+ # 'schermerhorn', 'schenker'
574
+ return %w[ER EN].include?(span(@position + 3, 2)) ? either("X", "SK", 3) : both("SK", 3)
575
+ end
576
+ return either("X", "S", 3) if @position.zero? && !vowel?(3) && at(3) != "W"
577
+
578
+ both("X", 3)
579
+ end
580
+
581
+ sig { returns(Step) }
582
+ def trailing_s
583
+ advance = %w[S Z].include?(at(@position + 1)) ? 2 : 1
584
+ # french e.g. 'resnais', 'artois'
585
+ french = @position == @last && %w[AI OI].include?(span(@position - 2, 2))
586
+
587
+ french ? either("", "S", advance) : both("S", advance)
588
+ end
589
+
590
+ sig { returns(Step) }
591
+ def letter_t
592
+ return both("X", 3) if span(@position, 4) == "TION"
593
+ return both("X", 3) if %w[TIA TCH].include?(span(@position, 3))
594
+ return letter_th if span(@position, 2) == "TH" || span(@position, 3) == "TTH"
595
+
596
+ both("T", %w[T D].include?(at(@position + 1)) ? 2 : 1)
597
+ end
598
+
599
+ sig { returns(Step) }
600
+ def letter_th
601
+ # special case 'thomas', 'thames', or germanic
602
+ return both("T", 2) if %w[OM AM].include?(span(@position + 2, 2)) ||
603
+ ["VAN ", "VON "].include?(span(0, 4)) || span(0, 3) == "SCH"
604
+
605
+ either("0", "T", 2) # yes, zero: the character Philips uses for the 'th' sound
606
+ end
607
+
608
+ # 'W' is the one letter whose rules stack: an initial 'WA-' can be
609
+ # scored and then scored again by the ending rule below it, which is
610
+ # why this reads as an accumulator rather than as a chain of returns.
611
+ sig { returns(Step) }
612
+ def letter_w
613
+ return both("R", 2) if span(@position, 2) == "WR"
614
+
615
+ primary, secondary = initial_w
616
+ # 'Arnow' should match 'Arnoff'
617
+ return either(primary, "#{secondary}F", 1) if final_w?
618
+ # polish e.g. 'filipowicz'
619
+ return either("#{primary}TS", "#{secondary}FX", 4) if %w[WICZ WITZ].include?(span(@position, 4))
620
+
621
+ either(primary, secondary, 1) # else skip it
622
+ end
623
+
624
+ sig { returns([String, String]) }
625
+ def initial_w
626
+ return ["", ""] unless @position.zero? && (vowel?(@position + 1) || span(@position, 2) == "WH")
627
+ # 'Wasserman' should match 'Vasserman'; 'Uomo' should match 'Womo'
628
+ return %w[A F] if vowel?(@position + 1)
629
+
630
+ %w[A A]
631
+ end
632
+
633
+ sig { returns(T::Boolean) }
634
+ def final_w?
635
+ (@position == @last && vowel?(@position - 1)) ||
636
+ %w[EWSKI EWSKY OWSKI OWSKY].include?(span(@position - 1, 5)) ||
637
+ span(0, 3) == "SCH"
638
+ end
639
+
640
+ sig { returns(Step) }
641
+ def letter_x
642
+ advance = %w[C X].include?(at(@position + 1)) ? 2 : 1
643
+ # french e.g. 'breaux'
644
+ french = @position == @last &&
645
+ (%w[IAU EAU].include?(span(@position - 3, 3)) || %w[AU OU].include?(span(@position - 2, 2)))
646
+
647
+ french ? silent(advance) : both("KS", advance)
648
+ end
649
+
650
+ sig { returns(Step) }
651
+ def letter_z
652
+ return both("J", 2) if at(@position + 1) == "H" # chinese pinyin e.g. 'zhao'
653
+
654
+ advance = at(@position + 1) == "Z" ? 2 : 1
655
+ return either("S", "TS", advance) if %w[ZO ZI ZA].include?(span(@position + 1, 2)) ||
656
+ (@slavo_germanic && @position.positive? && at(@position - 1) != "T")
657
+
658
+ both("S", advance)
659
+ end
660
+ end
661
+ end
662
+ end