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,303 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module ActiveSanction
7
+ # Turns a candidate into an explainable score. Stage 4 of the matching
8
+ # pipeline, and the component that decides whether this library is
9
+ # trustworthy.
10
+ #
11
+ # subject = ActiveSanction::Scorer::Subject.new(
12
+ # name: "Abu Abbas", type: :individual, dates_of_birth: "1948", nationalities: %w[RU]
13
+ # )
14
+ #
15
+ # result = ActiveSanction::Scorer.call(subject, entity)
16
+ # result.score # => 87.4
17
+ # result.name.value # => "ABBAS, Abu"
18
+ # result.explanation
19
+ # # => [#<Reason +91.2 name: matched primary name 'ABBAS, Abu'>,
20
+ # # #<Reason +6.0 dob: date of birth 1948 overlaps listed 1948-12-10>,
21
+ # # #<Reason -12.0 nationality: query RU vs listed EG>]
22
+ #
23
+ # ActiveSanction::Scorer.call(subject, index.candidates("Abu Abbas").first)
24
+ #
25
+ # Everything upstream narrows: the normalizer (#26) makes two names
26
+ # comparable, the index (#31) says which are worth comparing, and Similarity
27
+ # (#28, #29) and Phonetics (#30) each answer one question about a pair. This
28
+ # stage is the only one that says *how much a match is worth*, and the only
29
+ # one whose output a person has to act on.
30
+ #
31
+ # ### An entity's score is the best of its names
32
+ #
33
+ # Matching any single alias is a hit. OFAC ships 20,147 aliases against
34
+ # 19,321 primary names and the UN publishes as many as a dozen spellings of
35
+ # one person, so a score that averaged over an entity's names would punish
36
+ # the records that describe themselves most thoroughly -- and a score that
37
+ # only read the primary name would miss most of what these lists are for.
38
+ #
39
+ # So every name is scored and the best one wins, and the winner is on the
40
+ # Result: a report has to be able to say which spelling produced the hit.
41
+ #
42
+ # The low-quality penalty is applied *before* the maximum rather than to the
43
+ # winner afterwards, which is the difference between two readings of the
44
+ # same rule. The UN grades some aliases `Low`, meaning the Committee itself
45
+ # is unsure the person is known by that name; a good name scoring 85 should
46
+ # beat a low-quality one scoring 90, and it only does if the penalty is part
47
+ # of what the maximum is taken over.
48
+ #
49
+ # A former name (`fka`) is not penalized. It is a name the person really
50
+ # used, and a screening tool that discounted it would be discounting exactly
51
+ # the alias someone changes their name to escape.
52
+ #
53
+ # ### An entity of the wrong type is not scored at all
54
+ #
55
+ # A subject that says `:individual` is never compared to a vessel, whatever
56
+ # the names look like. `NORTHERN STAR` is a ship and a person and the
57
+ # difference is not a matter of degree -- there is no score at which a
58
+ # compliance officer wants a ship in a list of people -- so this is a filter
59
+ # and not a penalty, and `call` returns nil.
60
+ #
61
+ # Vessels and aircraft are ~10% of the SDN list and carry name-like strings,
62
+ # which is why Entity has the types at all. A subject that gives no type is
63
+ # asking a different question and is scored against everything.
64
+ #
65
+ # ### Deterministic, to the last decimal
66
+ #
67
+ # The same subject and the same entity produce the same score and the same
68
+ # explanation on every run, in every process. That is not a nicety: a
69
+ # screening decision is re-derived during an audit months later, and a score
70
+ # that moved by a tenth because a Hash iterated differently is a decision
71
+ # nobody can defend.
72
+ #
73
+ # What that costs is small and worth naming, because each piece of it is a
74
+ # place the property could be lost. Names are scored in the order the
75
+ # publisher listed them and ties go to the first, so an entity carrying the
76
+ # same spelling twice does not depend on which copy was seen first. The
77
+ # adjustments run in a fixed order -- see Adjustments -- and each picks its
78
+ # pairing by iteration order rather than by anything sorted on a Float.
79
+ # Rounding happens once, in Reason, and the score is the sum of the rounded
80
+ # contributions rather than the rounded sum.
81
+ #
82
+ # ### `threshold:` is how a screening call fits in its budget
83
+ #
84
+ # This stage is nearly all of what a screening call costs -- a few hundred
85
+ # candidates, four string algorithms and a phonetic pass on each -- and
86
+ # scoring them exactly costs about 46 ms under YJIT where scoring them to a
87
+ # threshold of 75 costs about 16. The difference is not an approximation:
88
+ # everything a threshold turns off is a comparison whose result could not
89
+ # have changed the answer, and a result at or above the cutoff is exactly
90
+ # the result the same call without one returns. See NameScore for the
91
+ # arithmetic and `rake benchmark:scorer` for the sweep.
92
+ #
93
+ # So the caller in front of this should always pass one. A matcher that
94
+ # screens without a threshold spends three times its budget computing exact
95
+ # scores for candidates it is about to discard.
96
+ #
97
+ # It is applied to the whole score rather than to the name, which matters:
98
+ # a subject carrying the right passport number needs forty points less of a
99
+ # name than one carrying nothing, and a threshold applied to the name alone
100
+ # would drop exactly the hits the identifiers exist to find.
101
+ #
102
+ # ### Weights
103
+ #
104
+ # Every number this stage uses is in Weights, with a default and the reason
105
+ # for it. A host that disagrees passes its own, per call or in
106
+ # configuration:
107
+ #
108
+ # ActiveSanction.configure { |c| c.scorer_weights = { dob_conflict: -20.0 } }
109
+ #
110
+ # Changing them changes what a past decision would score today, so a stored
111
+ # decision records the weights it was made under -- which is #33's job, and
112
+ # the reason this stage takes them as an argument rather than reading a
113
+ # global halfway down a call stack.
114
+ module Scorer
115
+ extend T::Sig
116
+ extend T::Helpers
117
+
118
+ # Called as `Scorer.threshold!`, which is where `raise` comes from.
119
+ requires_ancestor { Kernel }
120
+
121
+ # The scale everything here works in. Similarity is 0..1; the conversion
122
+ # happens once, in NameScore.
123
+ #
124
+ # @api private
125
+ SCALE = T.let(100.0, Float)
126
+
127
+ module_function
128
+
129
+ # The best score this entity can make against this subject, or nil when
130
+ # there is nothing to score: an entity of the wrong type, or one whose
131
+ # every name folds away to nothing.
132
+ #
133
+ # Takes an Entity or an Index::Candidate, since the caller in front of
134
+ # this holds candidates and the caller in a console holds entities.
135
+ #
136
+ # `weights:` defaults to the configured set. It is read once per call
137
+ # rather than per name, so a configuration changed mid-call cannot produce
138
+ # a score that is half one set of weights and half another.
139
+ #
140
+ # `threshold:` is on the same 0..100 scale as the score. A result at or
141
+ # above it is exactly the result the same call without one returns; below
142
+ # it, nil. See the note on cost below for what it buys and why the caller
143
+ # in front of this should always pass one.
144
+ sig do
145
+ params(subject: Subject, candidate: T.untyped, weights: T.untyped, threshold: Numeric)
146
+ .returns(T.nilable(Result)).checked(:tests)
147
+ end
148
+ def call(subject, candidate, weights: nil, threshold: 0.0)
149
+ entity = candidate.is_a?(Entity) ? candidate : candidate.entity
150
+ return nil unless comparable?(subject, entity)
151
+
152
+ settings = Weights.build(weights || ActiveSanction.config.scorer_weights)
153
+ cutoff = threshold!(threshold)
154
+ # The secondary identifiers first, though they are reported second: they
155
+ # cost no string comparison, and what they come to is what the name has
156
+ # to beat. A subject carrying the right passport number needs 40 points
157
+ # less of a name than one carrying nothing.
158
+ adjustments = Adjustments.call(subject, entity, settings)
159
+ best = best_name(subject, entity, settings, floor(cutoff, adjustments))
160
+ return nil if best.nil?
161
+
162
+ name, form, reasons = best
163
+ result = Result.new(entity: entity, name: name, form: form,
164
+ explanation: bounded(reasons + adjustments))
165
+ result.score < cutoff ? nil : result
166
+ end
167
+
168
+ # The least a name can score and still leave the entity able to reach the
169
+ # cutoff, given what the identifiers already came to.
170
+ #
171
+ # A cutoff of nothing is nothing, and the guard is not cosmetic: a stack
172
+ # of penalties can put a total below zero, where the floor clamps it back
173
+ # up, so `cutoff - adjustments` would demand a name score of 47 to reach a
174
+ # threshold of 0 and quietly return no result at all.
175
+ sig { params(cutoff: Float, adjustments: T::Array[Reason]).returns(Float).checked(:tests) }
176
+ def floor(cutoff, adjustments)
177
+ return 0.0 unless cutoff.positive?
178
+
179
+ [cutoff - adjustments.sum(&:contribution), 0.0].max
180
+ end
181
+
182
+ # A threshold as a Float, or a QueryError.
183
+ #
184
+ # This is the boundary where the library changes units: everything below
185
+ # this stage is a similarity on a 0..1 scale and everything above it is a
186
+ # percentage. Similarity checks for the mistake in one direction -- an 85
187
+ # arriving where 0.85 was meant, which would reject every pair and read as
188
+ # "nothing matched" -- and this checks the other, an argument outside
189
+ # 0..100 at all.
190
+ #
191
+ # What it deliberately cannot catch is a `0.75` meant as three-quarters,
192
+ # because 0.75 is a legitimate threshold and there is no way to tell the
193
+ # two apart. That mistake is the survivable one: a threshold far too low
194
+ # returns everything the index found rather than nothing, which is noisy
195
+ # and visible. It is the opposite error that hides a hit.
196
+ sig { params(threshold: Numeric).returns(Float).checked(:tests) }
197
+ def threshold!(threshold)
198
+ cutoff = threshold.to_f
199
+ return cutoff if cutoff.between?(0.0, SCALE)
200
+
201
+ raise QueryError,
202
+ "threshold must be between 0 and 100, got #{threshold.inspect} -- " \
203
+ "a screening score is a percentage, not a similarity on a 0..1 scale"
204
+ end
205
+
206
+ # Whether this entity is the kind of thing the subject asked about. A
207
+ # subject with no type asks about everything.
208
+ sig { params(subject: Subject, entity: Entity).returns(T::Boolean).checked(:tests) }
209
+ def comparable?(subject, entity) = subject.type.nil? || subject.type == entity.type
210
+
211
+ # One of an entity's names, as far as this stage takes it: the published
212
+ # name, its folded form, and the reasons that name produced.
213
+ #
214
+ # @api private
215
+ Scored = T.type_alias { [Name, Normalizer::Form, T::Array[Reason]] }
216
+
217
+ # Whichever of the entity's names scores highest once its own quality
218
+ # penalty is applied, or nil when none of them survives the fold or
219
+ # reaches the threshold.
220
+ #
221
+ # Each name is scored against the best one found so far as well as against
222
+ # the caller's threshold, since only the best is going to be reported. On
223
+ # an entity with a dozen aliases that is most of the work skipped: the
224
+ # second name only has to be compared closely enough to establish that it
225
+ # does not beat the first.
226
+ #
227
+ # Equal is not better, so a tie stays with the name the publisher listed
228
+ # first -- see the note on determinism above.
229
+ sig do
230
+ params(subject: Subject, entity: Entity, weights: Weights, threshold: Float)
231
+ .returns(T.nilable(Scored)).checked(:tests)
232
+ end
233
+ def best_name(subject, entity, weights, threshold)
234
+ best = T.let(nil, T.nilable(Scored))
235
+ highest = T.let(-Float::INFINITY, Float)
236
+ entity.names.each do |name|
237
+ # A name that folds away to nothing cannot be compared -- see
238
+ # Normalizer::Form#empty?, and Index, which skips the same names.
239
+ form = Normalizer.call(name, type: entity.type)
240
+ next if form.empty?
241
+
242
+ penalty = name.low_quality? ? weights.low_quality_alias : 0.0
243
+ cutoff = [threshold, highest].max - penalty
244
+ score = NameScore.call(subject.form, form, weights, threshold: cutoff.clamp(0.0, SCALE))
245
+ # A zero under a real cutoff means "could not reach it", not "scored
246
+ # nothing" -- and the two have to be told apart before the comparison
247
+ # below, which would otherwise take a name that was never measured.
248
+ next if score.zero? && cutoff.positive?
249
+ next unless score + penalty > highest
250
+
251
+ highest = score + penalty
252
+ best = [name, form, reasons(name, score, penalty)]
253
+ end
254
+ best
255
+ end
256
+
257
+ # What one name is worth: the blended similarity, and the penalty for a
258
+ # name its own publisher graded unreliable.
259
+ sig do
260
+ params(name: Name, score: Float, penalty: Float).returns(T::Array[Reason]).checked(:tests)
261
+ end
262
+ def reasons(name, score, penalty)
263
+ reasons = [Reason.new(factor: :name, detail: matched(name), contribution: score)]
264
+ return reasons if penalty.zero?
265
+
266
+ reasons << Reason.new(factor: :alias_quality, contribution: penalty,
267
+ detail: "#{name.value.inspect} is graded a low-quality alias by its publisher")
268
+ end
269
+
270
+ sig { params(name: Name).returns(String).checked(:tests) }
271
+ def matched(name)
272
+ kind = name.primary? ? "primary name" : "alias"
273
+ suffix = name.primary? ? "" : " (#{name.kind})"
274
+ "matched #{kind} #{name.value.inspect}#{suffix}"
275
+ end
276
+
277
+ # Keeps the score inside 0..100 without letting the explanation stop
278
+ # explaining it. A cap that silently swallowed 12 points would leave a
279
+ # reviewer adding a column of figures that does not reach the number
280
+ # printed above it, so the correction is itself a reason.
281
+ #
282
+ # It fires rarely -- a decisive identifier boost on an already-strong name
283
+ # is the usual way -- and when it does, the fact that a score reached its
284
+ # ceiling with points to spare is worth seeing.
285
+ sig { params(reasons: T::Array[Reason]).returns(T::Array[Reason]).checked(:tests) }
286
+ def bounded(reasons)
287
+ total = reasons.sum(&:contribution).round(Reason::PRECISION)
288
+ capped = total.clamp(0.0, SCALE)
289
+ return reasons if capped == total
290
+
291
+ reasons + [Reason.new(factor: :clamp, contribution: capped - total,
292
+ detail: "#{total.round(Reason::PRECISION)} #{capped.zero? ? "raised" : "capped"} " \
293
+ "to #{capped}")]
294
+ end
295
+ end
296
+ end
297
+
298
+ require "active_sanction/scorer/weights"
299
+ require "active_sanction/scorer/reason"
300
+ require "active_sanction/scorer/subject"
301
+ require "active_sanction/scorer/name_score"
302
+ require "active_sanction/scorer/adjustments"
303
+ require "active_sanction/scorer/result"
@@ -0,0 +1,210 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module ActiveSanction
7
+ module Similarity
8
+ # Jaro-Winkler similarity, 0..1.
9
+ #
10
+ # ActiveSanction::Similarity::JaroWinkler.call("martha", "marhta") # => 0.9611
11
+ # ActiveSanction::Similarity::JaroWinkler.call("dixon", "dicksonx") # => 0.8133
12
+ #
13
+ # ### Why this one
14
+ #
15
+ # Jaro counts the characters two strings share within a window that widens
16
+ # with their length, and charges half an edit for each pair that matched
17
+ # out of order. That is a good model of how names are actually
18
+ # mistyped -- `MARHTA` for `MARTHA` is one transposition and two
19
+ # substitutions to Levenshtein, which scores it 0.667 against Jaro's 0.944.
20
+ #
21
+ # Winkler's addition is a bonus for a shared prefix, on the observation
22
+ # that people get the beginning of a name right and drift later. Names are
23
+ # the case that observation was drawn from and the case it holds best for:
24
+ # a transliterator's choice of vowel, a clerk's spelling of a suffix, and
25
+ # a truncated field all differ at the end.
26
+ #
27
+ # The prefix bonus is also the reason this is not the only scorer in the
28
+ # pipeline. It rewards `SMITH` against `SMITHSON`, and it has nothing at
29
+ # all to say about `ABBAS, Abu` against `Abu Abbas`, which shares no
30
+ # prefix and is the single most common query shape against these lists.
31
+ # TokenSort and TokenSet are what answer that.
32
+ module JaroWinkler
33
+ extend T::Sig
34
+
35
+ # Winkler's constants, and they are his: 0.1 with a four-character cap
36
+ # is what the 1990 paper used and what every published reference value
37
+ # is computed against. `p * l` cannot exceed 0.4, which is what keeps
38
+ # the bonus from pushing a score past 1.0.
39
+ PREFIX_SCALE = T.let(0.1, Float)
40
+ MAX_PREFIX = T.let(4, Integer)
41
+
42
+ # The bonus applies only to pairs that already look alike, which is
43
+ # Winkler's own rule and worth keeping for a reason this domain cares
44
+ # about: without it, every name beginning `MOHAMMED` is pulled toward
45
+ # every other one regardless of what follows, and a list where a quarter
46
+ # of the entries share a given name is exactly where that shows up as
47
+ # false positives.
48
+ BOOST_THRESHOLD = T.let(0.7, Float)
49
+
50
+ module_function
51
+
52
+ # The similarity of two already-folded strings -- see Similarity for
53
+ # what `threshold:` does and what it promises.
54
+ sig { params(left: String, right: String, threshold: Numeric).returns(Float).checked(:tests) }
55
+ def call(left, right, threshold: 0.0)
56
+ cutoff = Similarity.threshold!(threshold)
57
+ return 1.0 if left == right
58
+
59
+ left_codes = Similarity.codepoints(left)
60
+ right_codes = Similarity.codepoints(right)
61
+ return 0.0 if ceiling(left_codes.size, right_codes.size) < cutoff
62
+
63
+ score = winkler(left_codes, right_codes, jaro_score(left_codes, right_codes))
64
+ score < cutoff ? 0.0 : score
65
+ end
66
+
67
+ # The highest score two strings of these lengths can reach, whatever
68
+ # they contain. This is the early exit: a pair whose ceiling is under
69
+ # the caller's threshold is rejected without either string being looked
70
+ # at.
71
+ #
72
+ # At most `shorter` characters can match, so the two coverage terms are
73
+ # bounded by 1 and `shorter / longer` and the transposition term by 1;
74
+ # the prefix bonus on top can be no larger than a prefix of `shorter`
75
+ # allows. The bound is loose -- a 0.85 threshold only rejects a pair
76
+ # whose lengths differ by more than 4x, because the prefix bonus is
77
+ # generous about it -- and loose is the only safe direction. A ceiling
78
+ # that ever came in under a real score would drop true matches, so it is
79
+ # derived rather than tuned, and the specs hold it to that against every
80
+ # pair they can build.
81
+ sig { params(left_length: Integer, right_length: Integer).returns(Float).checked(:tests) }
82
+ def ceiling(left_length, right_length)
83
+ shorter = [left_length, right_length].min
84
+ longer = [left_length, right_length].max
85
+ return shorter == longer ? 1.0 : 0.0 if shorter.zero?
86
+
87
+ jaro = (2.0 + shorter.fdiv(longer)) / 3.0
88
+ jaro + ([shorter, MAX_PREFIX].min * PREFIX_SCALE * (1.0 - jaro))
89
+ end
90
+
91
+ # Jaro on its own, without the prefix bonus. Public because it is the
92
+ # published quantity -- a reference table gives both -- and because the
93
+ # scorer may yet want the unboosted number for a pair whose shared
94
+ # prefix is the part a caller has least confidence in.
95
+ sig { params(left: String, right: String).returns(Float).checked(:tests) }
96
+ def jaro(left, right)
97
+ return 1.0 if left == right
98
+
99
+ jaro_score(Similarity.codepoints(left), Similarity.codepoints(right))
100
+ end
101
+
102
+ # Codepoints in, for the reason Similarity.codepoints gives. `left` and
103
+ # `right` mean the same two names here as they do above; only the
104
+ # representation changes, and the signatures say which is which.
105
+ sig { params(left: T::Array[Integer], right: T::Array[Integer]).returns(Float).checked(:tests) }
106
+ def jaro_score(left, right)
107
+ return 0.0 if left.empty? || right.empty?
108
+
109
+ window = ([left.size, right.size].max / 2) - 1
110
+ window = 0 if window.negative?
111
+ left_matched, right_matched, matches = match(left, right, window)
112
+ return 0.0 if matches.zero?
113
+
114
+ halved = transpositions(left, right, left_matched, right_matched) / 2.0
115
+ (matches.fdiv(left.size) + matches.fdiv(right.size) + ((matches - halved) / matches)) / 3.0
116
+ end
117
+
118
+ # Which characters of each string found a partner in the other, and how
119
+ # many did.
120
+ #
121
+ # A character matches at most once, and only within `window` positions
122
+ # of where it sits in the other string -- that window is what makes this
123
+ # a similarity between two names rather than a bag-of-letters count, and
124
+ # what keeps `ORWELL` from scoring highly against `LLEWRO`.
125
+ sig do
126
+ params(left: T::Array[Integer], right: T::Array[Integer], window: Integer)
127
+ .returns([T::Array[T::Boolean], T::Array[T::Boolean], Integer])
128
+ .checked(:tests)
129
+ end
130
+ def match(left, right, window)
131
+ left_matched = Array.new(left.size, false)
132
+ right_matched = Array.new(right.size, false)
133
+ last = right.size - 1
134
+ matches = 0
135
+ left.each_with_index do |code, i|
136
+ low = i > window ? i - window : 0
137
+ j = partner(right, right_matched, code, low, [i + window, last].min)
138
+ next if j.nil?
139
+
140
+ left_matched[i] = true
141
+ right_matched[j] = true
142
+ matches += 1
143
+ end
144
+ [left_matched, right_matched, matches]
145
+ end
146
+
147
+ # The first position in `right[low..high]` holding `code` and not already
148
+ # spoken for, or nil. Leftmost, which is what makes the pairing
149
+ # deterministic: `call(x, y)` has to return the same number every time
150
+ # it is asked, because a screening decision is re-derived during an
151
+ # audit.
152
+ sig do
153
+ params(right: T::Array[Integer], matched: T::Array[T::Boolean], code: Integer, low: Integer, high: Integer)
154
+ .returns(T.nilable(Integer))
155
+ .checked(:tests)
156
+ end
157
+ def partner(right, matched, code, low, high)
158
+ j = low
159
+ while j <= high
160
+ return j if !matched[j] && right.fetch(j) == code
161
+
162
+ j += 1
163
+ end
164
+ nil
165
+ end
166
+
167
+ # Matched characters that came out in a different order on each side.
168
+ # Walking both sides in step, every position where the two disagree is
169
+ # half of a transposition, which is why the caller halves the count.
170
+ sig do
171
+ params(left: T::Array[Integer], right: T::Array[Integer],
172
+ left_matched: T::Array[T::Boolean], right_matched: T::Array[T::Boolean])
173
+ .returns(Integer)
174
+ .checked(:tests)
175
+ end
176
+ def transpositions(left, right, left_matched, right_matched)
177
+ count = 0
178
+ k = 0
179
+ left.each_with_index do |code, i|
180
+ next unless left_matched[i]
181
+
182
+ k += 1 until right_matched.fetch(k)
183
+ count += 1 unless code == right.fetch(k)
184
+ k += 1
185
+ end
186
+ count
187
+ end
188
+
189
+ # The prefix bonus.
190
+ sig do
191
+ params(left: T::Array[Integer], right: T::Array[Integer], jaro: Float).returns(Float).checked(:tests)
192
+ end
193
+ def winkler(left, right, jaro)
194
+ return jaro if jaro < BOOST_THRESHOLD
195
+
196
+ jaro + (prefix_length(left, right) * PREFIX_SCALE * (1.0 - jaro))
197
+ end
198
+
199
+ sig { params(left: T::Array[Integer], right: T::Array[Integer]).returns(Integer).checked(:tests) }
200
+ def prefix_length(left, right)
201
+ limit = [MAX_PREFIX, left.size, right.size].min
202
+ length = 0
203
+ length += 1 while length < limit && left.fetch(length) == right.fetch(length)
204
+ length
205
+ end
206
+
207
+ private_class_method :jaro_score, :match, :partner, :transpositions, :winkler, :prefix_length
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,179 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module ActiveSanction
7
+ module Similarity
8
+ # Levenshtein edit distance, and the similarity derived from it, 0..1.
9
+ #
10
+ # ActiveSanction::Similarity::Levenshtein.distance("kitten", "sitting") # => 3
11
+ # ActiveSanction::Similarity::Levenshtein.call("kitten", "sitting") # => 0.5714
12
+ #
13
+ # ### Why this one, next to Jaro-Winkler
14
+ #
15
+ # It answers a different question. Jaro-Winkler asks how many characters
16
+ # two names share and how far out of order they are, and it is generous:
17
+ # it has no way to charge for a name that is simply longer than the other,
18
+ # and its prefix bonus is deliberately biased toward the front. Edit
19
+ # distance counts what it would actually take to turn one string into the
20
+ # other, which is the thing that stays honest when a name gains a whole
21
+ # word.
22
+ #
23
+ # JaroWinkler.call("gazprom", "gazprombank") # => 0.9273
24
+ # Levenshtein.call("gazprom", "gazprombank") # => 0.6364
25
+ #
26
+ # Two different companies, and the scorer (#32) blends the two numbers
27
+ # precisely so that neither algorithm's blind spot decides a hit on its
28
+ # own.
29
+ #
30
+ # ### Normalized by the longer string
31
+ #
32
+ # `1 - distance / longer.length`, so the result is comparable across name
33
+ # lengths and against Jaro-Winkler. Dividing by the longer of the two is
34
+ # what makes the measure symmetric and keeps it in 0..1: the distance can
35
+ # never exceed the longer length, and it equals it exactly when the two
36
+ # share nothing.
37
+ #
38
+ # ### What else runs on it
39
+ #
40
+ # Both token ratios. TokenSort is one call to this on a pair of names
41
+ # whose tokens have been put in alphabetical order, and TokenSet is three
42
+ # on a pair that has been split into what they share and what they do not,
43
+ # so half of stage 3 is this file with the strings rearranged first. See
44
+ # TokenSort for why the rearranging is not done on top of Jaro-Winkler.
45
+ module Levenshtein
46
+ extend T::Sig
47
+
48
+ module_function
49
+
50
+ # The similarity of two already-folded strings -- see Similarity for
51
+ # what `threshold:` does and what it promises.
52
+ sig { params(left: String, right: String, threshold: Numeric).returns(Float).checked(:tests) }
53
+ def call(left, right, threshold: 0.0)
54
+ cutoff = Similarity.threshold!(threshold)
55
+ return 1.0 if left == right
56
+
57
+ left_codes = Similarity.codepoints(left)
58
+ right_codes = Similarity.codepoints(right)
59
+ longer = [left_codes.size, right_codes.size].max
60
+ return 0.0 if ceiling(left_codes.size, right_codes.size) < cutoff
61
+
62
+ # An edit budget rather than a score: within the budget the score is
63
+ # at or above the threshold, and the rows stop the moment a row's
64
+ # smallest value passes it.
65
+ distance = rows(left_codes, right_codes, ((1.0 - cutoff) * longer).floor)
66
+ return 0.0 if distance.nil?
67
+
68
+ score = 1.0 - distance.fdiv(longer)
69
+ score < cutoff ? 0.0 : score
70
+ end
71
+
72
+ # The number of single-character insertions, deletions and substitutions
73
+ # that turn one string into the other. Exact, and public because it is
74
+ # the quantity people know: a caller who wants "within two typos"
75
+ # already knows what to compare against, and asking that of a 0..1 score
76
+ # means multiplying by a length.
77
+ sig { params(left: String, right: String).returns(Integer).checked(:tests) }
78
+ def distance(left, right)
79
+ return 0 if left == right
80
+
81
+ left_codes = Similarity.codepoints(left)
82
+ right_codes = Similarity.codepoints(right)
83
+
84
+ # No cell can exceed the longer length, so a budget of it never fires
85
+ # and the walk is exhaustive.
86
+ T.must(rows(left_codes, right_codes, [left_codes.size, right_codes.size].max))
87
+ end
88
+
89
+ # The highest score two strings of these lengths can reach, whatever
90
+ # they contain. This is the early exit: a pair whose ceiling is under
91
+ # the caller's threshold is rejected without either string being looked
92
+ # at, and without the matrix.
93
+ #
94
+ # Turning the shorter string into the longer one costs at least the
95
+ # difference in their lengths -- every missing character is an
96
+ # insertion, however well the rest lines up -- so the score can be no
97
+ # better than `shorter / longer`. Unlike Jaro-Winkler's ceiling this one
98
+ # is tight, and it bites: at a 0.85 threshold it rejects every pair
99
+ # whose lengths differ by more than 15%, which on these lists is most of
100
+ # the corpus for a given query.
101
+ sig { params(left_length: Integer, right_length: Integer).returns(Float).checked(:tests) }
102
+ def ceiling(left_length, right_length)
103
+ shorter = [left_length, right_length].min
104
+ longer = [left_length, right_length].max
105
+ return shorter == longer ? 1.0 : 0.0 if shorter.zero?
106
+
107
+ shorter.fdiv(longer)
108
+ end
109
+
110
+ # The matrix, one row at a time, or nil once no row can lead to a
111
+ # distance within `max`.
112
+ #
113
+ # Two rows rather than the full grid: a cell depends on the one above
114
+ # it, the one to its left, and the one diagonally above-left, so nothing
115
+ # older than the previous row is ever read again. The full matrix for a
116
+ # pair of 40-character names is 1,681 cells held for no reason, and the
117
+ # scorer runs this a few hundred times per query.
118
+ #
119
+ # The cutoff is what makes `threshold:` worth more than the length check
120
+ # in `ceiling`: row minima never decrease as the walk descends, so once
121
+ # a row's smallest value is past the budget, no later row and no final
122
+ # cell can come back under it.
123
+ sig do
124
+ params(left: T::Array[Integer], right: T::Array[Integer], max: Integer)
125
+ .returns(T.nilable(Integer))
126
+ .checked(:tests)
127
+ end
128
+ def rows(left, right, max)
129
+ previous = (0..right.size).to_a
130
+ current = Array.new(right.size + 1, 0)
131
+ left.each_with_index do |code, i|
132
+ current[0] = i + 1
133
+ return nil if fill(code, right, previous, current) > max
134
+
135
+ previous, current = current, previous
136
+ end
137
+ previous.fetch(right.size)
138
+ end
139
+
140
+ # One row, and the smallest value in it. Three of the four numbers a
141
+ # cell needs are already in hand -- the substitution and deletion costs
142
+ # come from the same two positions of the previous row, and the
143
+ # insertion cost is the cell just written -- so only one read of each
144
+ # row and one of the string happen per cell.
145
+ #
146
+ # `fetch` rather than `[]` for those: this is the innermost loop in the
147
+ # library, and Sorbet types `Array#[]` as nilable, so every read through
148
+ # it would carry a `T.must`, which is a Ruby-level method call per cell.
149
+ sig do
150
+ params(code: Integer, right: T::Array[Integer], previous: T::Array[Integer], current: T::Array[Integer])
151
+ .returns(Integer)
152
+ .checked(:tests)
153
+ end
154
+ def fill(code, right, previous, current)
155
+ # The cell to the left is the one just written and the cell
156
+ # diagonally above-left is the one that was above, so both are carried
157
+ # in locals rather than read back out of the rows.
158
+ left_cell = current.fetch(0)
159
+ smallest = left_cell
160
+ diagonal = previous.fetch(0)
161
+ width = right.size
162
+ j = 0
163
+ while j < width
164
+ above = previous.fetch(j + 1)
165
+ value = diagonal + (right.fetch(j) == code ? 0 : 1) # substitution
166
+ value = above + 1 if above < value # deletion
167
+ value = left_cell + 1 if left_cell < value # insertion
168
+ diagonal = above
169
+ left_cell = current[j + 1] = value
170
+ smallest = value if value < smallest
171
+ j += 1
172
+ end
173
+ smallest
174
+ end
175
+
176
+ private_class_method :rows, :fill
177
+ end
178
+ end
179
+ end