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,279 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "active_sanction/phonetics"
7
+ require "active_sanction/similarity"
8
+
9
+ module ActiveSanction
10
+ module Scorer
11
+ # How close two folded names are, on the 0..100 scale a screening decision
12
+ # is made on.
13
+ #
14
+ # left = ActiveSanction::Normalizer.call("ABBAS, Abu")
15
+ # right = ActiveSanction::Normalizer.call("Abu Abbas")
16
+ #
17
+ # ActiveSanction::Scorer::NameScore.call(left, right) # => 90.4
18
+ # ActiveSanction::Scorer::NameScore.ratios(left, right)
19
+ # # => { jaro_winkler: 0.805, levenshtein: 0.333, token_sort: 1.0,
20
+ # # token_set: 1.0, phonetic: 1.0 }
21
+ #
22
+ # Stage 4a. Everything in Similarity answers one question about a pair of
23
+ # names and answers it well; this is the one place in the library that
24
+ # decides what those four answers are worth together, and Weights is why
25
+ # each is worth what it is.
26
+ #
27
+ # ### The blend is a weighted mean, and that is a choice
28
+ #
29
+ # The obvious alternative is a weighted maximum -- take the best evidence
30
+ # any of the four found, discounted by how tolerant that comparison is --
31
+ # which is what the well-known Python ratio does. It is rejected here for
32
+ # one reason: `token_set` returns 1.0 whenever one name's words are a
33
+ # subset of the other's, so a maximum would score the query `Mohammed`
34
+ # against `MOHAMMED AL-ZAWAHIRI` in the nineties. On a corpus where a
35
+ # quarter of the individuals share a handful of given names, that is not a
36
+ # tolerance, it is an alert queue nobody can work through.
37
+ #
38
+ # A mean makes the four disagree in public instead. The same pair comes
39
+ # out in the high seventies -- still high, because the caller's whole query
40
+ # really is on the record, and that is the honest answer -- and what pulls
41
+ # it apart from a real match is not the name at all. It is the date of
42
+ # birth and the passport number, which is exactly the argument the issue
43
+ # this implements makes: name alone produces enormous false-positive
44
+ # volume on common names, and identifiers are the corrective.
45
+ #
46
+ # ### The phonetic share
47
+ #
48
+ # Double Metaphone answers with a key rather than a number, so it is
49
+ # turned into one the only way that respects what a key means: the
50
+ # fraction of the shorter name's tokens that have a token on the other
51
+ # side sounding like them. `QADHAFI, Muammar` against `Muammar Gaddafi` is
52
+ # 1.0 -- both tokens have a partner -- and one token in three agreeing is
53
+ # 0.33.
54
+ #
55
+ # Per token rather than over the whole name, because that is how the keys
56
+ # are built (see Phonetics: a token handed over on its own is what the
57
+ # index and this both key on) and because a name is rearranged as often as
58
+ # it is respelled.
59
+ #
60
+ # ### What this stage cannot do, and what covers it
61
+ #
62
+ # One name transliterated two different ways scores poorly here, and
63
+ # raising the phonetic share does not fix it. `QADHAFI, Muammar` against
64
+ # `Muammar Gaddafi` comes out at 58.8: the token ratios see two words with
65
+ # two letters different and one word matching, the character algorithms
66
+ # see less than that, and the phonetic share is one of five. Pushing that
67
+ # share to 0.15 moves the pair to 66.8 -- still under any threshold worth
68
+ # setting, while lifting every common-name near-miss by the same few
69
+ # points. It buys nothing and costs precision, so it is not done.
70
+ #
71
+ # What actually covers the case is upstream and is the reason this stage
72
+ # is built the way it is. These lists publish the variants themselves:
73
+ # OFAC's Qadhafi record carries `QADHAFI`, `QADAFI`, `GADAFI`, `KADAFI`
74
+ # and half a dozen more as aliases, because a sanctions list whose
75
+ # spelling had to be guessed would not work either. The index (#31) keys
76
+ # on Double Metaphone so that a query for one spelling retrieves a record
77
+ # filed under another, and the scorer takes the *maximum over an entity's
78
+ # names* -- so the query meets the alias it is actually a spelling of and
79
+ # is scored against that one instead. `Muammar Gaddafi` against the
80
+ # `GADDAFI, Muammar` alias is 84.8, where the same query against the
81
+ # `QADHAFI, Muammar` primary name is 58.8. See Scorer.
82
+ #
83
+ # The residue is a record that carries one spelling and one only, queried
84
+ # with a different one. That is a real recall limitation, it is stated
85
+ # rather than papered over, and the honest mitigation is the identifier
86
+ # fields rather than a bigger number in Weights.
87
+ #
88
+ # ### Cost, and why `threshold:` is most of it
89
+ #
90
+ # This runs a few hundred times per screening call and is very nearly all
91
+ # of what one costs -- the identifier adjustments, the fold and the
92
+ # explanation together are under 2% of it. Unthresholded, the five shares
93
+ # come to about 280 us per pair of names without a JIT and 120 with one,
94
+ # which puts a 200-candidate query at roughly 105 ms and 46 ms. Neither of
95
+ # those is a screening call.
96
+ #
97
+ # `threshold:` is what makes it one, and it keeps exactly the promise
98
+ # Similarity's does: a score at or above the threshold is the same Float
99
+ # the same call without one returns, and anything below is reported as 0.0
100
+ # rather than computed. Two mechanisms, both exact:
101
+ #
102
+ # **The shares are measured one at a time and the sum is bounded as they
103
+ # go.** Everything still unmeasured is worth at most its own weight, so
104
+ # `total + remaining` is the highest this pair can still reach; when that
105
+ # falls under the cutoff, the rest is not measured. On a candidate that
106
+ # was never going to clear, that is usually two of the five.
107
+ #
108
+ # **What is measured is measured with a threshold of its own.** Given the
109
+ # weights left to come, the least this share could be worth and still
110
+ # leave the pair reachable is arithmetic, and it is handed down as the
111
+ # algorithm's own `threshold:` -- where Levenshtein turns it into an edit
112
+ # budget and stops its rows early, and Jaro-Winkler rejects on length
113
+ # before looking at a character.
114
+ #
115
+ # Together, on a 200-candidate query against a full-size corpus:
116
+ #
117
+ # threshold no jit yjit results
118
+ # 0 105.6 ms 46.3 ms 184.0
119
+ # 50 85.6 ms 37.2 ms 118.2
120
+ # 75 37.5 ms 16.3 ms 31.4
121
+ # 85 24.0 ms 10.5 ms 10.6
122
+ #
123
+ # `rake benchmark:scorer` prints that sweep and is how to take it again on
124
+ # another machine. The scores that come back are unchanged, which is what
125
+ # the benchmark checks on every run and what the suite holds this to --
126
+ # the exits are bounds on what a pair can reach, never approximations of
127
+ # what it did reach.
128
+ #
129
+ # The threshold is therefore not an optional refinement for the caller in
130
+ # front of this. A matcher that screens without one spends three times the
131
+ # budget computing exact scores for candidates it is about to discard.
132
+ #
133
+ # Two smaller things keep the unthresholded path honest as well. Tokens
134
+ # are handed to the token ratios as the arrays a Form already holds, so
135
+ # nothing is split per comparison; and the phonetic pass skips any token
136
+ # that appears on both sides verbatim, which on a real match is most of
137
+ # them, so Double Metaphone runs on the tokens that actually differ.
138
+ #
139
+ #
140
+ # @api private
141
+ module NameScore
142
+ extend T::Sig
143
+ extend T::Helpers
144
+
145
+ # Called as `NameScore.measure`, which is where `raise` comes from.
146
+ requires_ancestor { Kernel }
147
+
148
+ # The scale a screening score is read and thresholded on. Similarity
149
+ # works in 0..1 and never rounds; the conversion happens once, here.
150
+ SCALE = T.let(100.0, Float)
151
+
152
+ # The order the shares are measured in, which is the only reason this
153
+ # differs from the order Weights lists them in. It is measured rather
154
+ # than argued -- `rake benchmark:scorer` is what produced it -- and what
155
+ # it optimizes is not cost per share but how quickly the bound tightens
156
+ # for what each share costs.
157
+ #
158
+ # **Jaro-Winkler first**, at about 18 us the cheapest of the five, and
159
+ # it resolves 0.15 of the weight before anything expensive runs.
160
+ #
161
+ # **The token set ratio second**, though at about 80 us it is the
162
+ # dearest of the four that can be thresholded: it carries 0.45 of the
163
+ # weight on its own, which is most of what the bound needs, and it is
164
+ # the measure that separates a candidate worth finishing from one that
165
+ # is not. Running it early is what lets a poor candidate be abandoned
166
+ # two measures in rather than four.
167
+ #
168
+ # **The phonetic pass last, always.** It is the smallest share at 0.05
169
+ # and it is also, at about 85 us, the most expensive thing here -- the
170
+ # one measure that cannot be given a threshold of its own, because
171
+ # Double Metaphone answers with a key and has no early exit to offer.
172
+ # Its cost is paid in full whenever it is paid at all, so last is where
173
+ # it is paid least often: by then the bound is within 0.05 of settled,
174
+ # and almost every candidate has already been decided.
175
+ ORDER = T.let(%i[jaro_winkler token_set token_sort levenshtein phonetic].freeze, T::Array[Symbol])
176
+
177
+ module_function
178
+
179
+ # The blended similarity of two folded names, 0..100 and unrounded --
180
+ # Reason rounds once, where the number becomes something a person reads.
181
+ #
182
+ # `threshold:` is on the same 0..100 scale as the answer, and it is an
183
+ # optimization rather than a filter: a pair that cannot reach it comes
184
+ # back 0.0 instead of being finished. See the note on cost above.
185
+ sig do
186
+ params(left: Normalizer::Form, right: Normalizer::Form, weights: Weights, threshold: Numeric)
187
+ .returns(Float).checked(:tests)
188
+ end
189
+ def call(left, right, weights = Weights.default, threshold: 0.0)
190
+ cutoff = (threshold.to_f / SCALE).clamp(0.0, 1.0)
191
+ total = 0.0
192
+ remaining = 1.0
193
+ ORDER.each do |share|
194
+ weight = weights.fetch(share)
195
+ return 0.0 if total + remaining < cutoff
196
+
197
+ remaining -= weight
198
+ total += weight * measure(share, left, right, floor(cutoff, total, remaining, weight))
199
+ end
200
+ total < cutoff ? 0.0 : SCALE * total
201
+ end
202
+
203
+ # The least this share can be worth while leaving the pair able to reach
204
+ # the cutoff, given everything already measured and everything still to
205
+ # come at its best. Zero when the pair can reach the cutoff whatever
206
+ # this share says, which is what a threshold of nothing always produces.
207
+ sig do
208
+ params(cutoff: Float, total: Float, remaining: Float, weight: Float).returns(Float).checked(:tests)
209
+ end
210
+ def floor(cutoff, total, remaining, weight)
211
+ return 0.0 unless weight.positive?
212
+
213
+ ((cutoff - total - remaining) / weight).clamp(0.0, 1.0)
214
+ end
215
+
216
+ # One share, with its own early exit. A share a host has weighted to
217
+ # zero is not measured at all.
218
+ sig do
219
+ params(share: Symbol, left: Normalizer::Form, right: Normalizer::Form, threshold: Float)
220
+ .returns(Float).checked(:tests)
221
+ end
222
+ def measure(share, left, right, threshold = 0.0)
223
+ case share
224
+ when :jaro_winkler then Similarity::JaroWinkler.call(left.value, right.value, threshold: threshold)
225
+ when :levenshtein then Similarity::Levenshtein.call(left.value, right.value, threshold: threshold)
226
+ when :token_sort then Similarity::TokenSort.call(left.tokens, right.tokens, threshold: threshold)
227
+ when :token_set then Similarity::TokenSet.call(left.tokens, right.tokens, threshold: threshold)
228
+ when :phonetic then phonetic(left.tokens, right.tokens)
229
+ else raise InvalidArgument, "unknown name share #{share.inspect}"
230
+ end
231
+ end
232
+
233
+ # What each of the five actually said, which is the whole of the
234
+ # difference between a score and a number.
235
+ #
236
+ # Public for the reason `TokenSort.sorted` and `TokenSet.strings` are
237
+ # public: a hit a compliance user cannot account for is a hit they
238
+ # cannot clear, and an analyst asking why two names scored what they did
239
+ # gets the answer by printing this beside the weights.
240
+ sig do
241
+ params(left: Normalizer::Form, right: Normalizer::Form).returns(T::Hash[Symbol, Float]).checked(:tests)
242
+ end
243
+ def ratios(left, right)
244
+ Weights::NAME_SHARES.to_h { |share| [share, measure(share, left, right)] }
245
+ end
246
+
247
+ # The fraction of the shorter name's tokens that sound like a token of
248
+ # the longer one.
249
+ #
250
+ # The shorter side is the denominator on purpose: a query of two tokens
251
+ # against a record of four should not be capped at 0.5 for the two the
252
+ # record has and the query does not. That is what `token_set` is already
253
+ # measuring, in a share of its own.
254
+ sig { params(left: T::Array[String], right: T::Array[String]).returns(Float).checked(:tests) }
255
+ def phonetic(left, right)
256
+ return 0.0 if left.empty? || right.empty?
257
+
258
+ shorter, longer = left.size <= right.size ? [left, right] : [right, left]
259
+ keys = T.let(nil, T.nilable(T::Array[String]))
260
+ agreed = shorter.count do |token|
261
+ # Identical tokens sound identical, and skipping them is what keeps
262
+ # the phonetic pass off the hot path of a real match.
263
+ next true if longer.include?(token)
264
+
265
+ keys ||= sounds(longer)
266
+ Phonetics::DoubleMetaphone.call(token).intersect?(keys)
267
+ end
268
+ agreed.fdiv(shorter.size)
269
+ end
270
+
271
+ # Every Double Metaphone key of every token, primary and alternate --
272
+ # see Phonetics for why the alternate is not optional. Flattened across
273
+ # the tokens because the question here is whether *any* token of the
274
+ # longer name sounds like the one being tested.
275
+ sig { params(tokens: T::Array[String]).returns(T::Array[String]).checked(:tests) }
276
+ def sounds(tokens) = tokens.flat_map { |token| Phonetics::DoubleMetaphone.call(token) }
277
+ end
278
+ end
279
+ end
@@ -0,0 +1,136 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module ActiveSanction
7
+ module Scorer
8
+ # One line of the answer to "why did this score 87?".
9
+ #
10
+ # ActiveSanction::Scorer::Reason.new(
11
+ # factor: :dob, detail: "year 1948 matches", contribution: 6.0
12
+ # )
13
+ #
14
+ # ### The explanation adds up
15
+ #
16
+ # A Result's `explanation` is a list of these, and their contributions sum
17
+ # to exactly the score. That is the whole design: the name blend produces
18
+ # the first one and every secondary identifier appends a signed delta, so
19
+ # a reviewer reading the list downwards arrives at the number on the
20
+ # report rather than at something near it.
21
+ #
22
+ # It is a property the suite holds rather than a coincidence of the
23
+ # arithmetic. Contributions are rounded once, here, and the score is the
24
+ # sum of the rounded values -- not the rounded sum, which is how a total
25
+ # ends up one tenth away from the figures printed beside it. Where
26
+ # clamping moves the total off the sum, that correction is itself a reason
27
+ # (`:clamp`), because a score that silently stopped at 100 is a score
28
+ # whose explanation no longer explains it.
29
+ #
30
+ # ### Why this is a required output and not a debugging aid
31
+ #
32
+ # A compliance officer has to defend a screening decision to an examiner,
33
+ # and "the library said 87" is not a defence. Both directions matter: an
34
+ # alert that cannot be accounted for cannot be cleared, and a *clearance*
35
+ # that cannot be accounted for is the one an examiner asks about. So every
36
+ # adjustment the scorer makes writes one of these, including the ones that
37
+ # lower a score.
38
+ #
39
+ # Instances are frozen on construction and compare by value.
40
+ class Reason
41
+ extend T::Sig
42
+
43
+ # `:name` is the blended name similarity and is always first. The rest
44
+ # are the secondary-identifier adjustments, in the order Adjustments
45
+ # applies them, and `:clamp` is the correction described above.
46
+ #
47
+ # Closed, so a typo is caught where the reason is built rather than
48
+ # reaching a report as a factor nothing renders.
49
+ #
50
+ # @api private
51
+ FACTORS = T.let(%i[name alias_quality identifier dob nationality clamp].freeze, T::Array[Symbol])
52
+
53
+ # One decimal place, which is the precision a screening score is read at
54
+ # -- see the note on adding up above.
55
+ #
56
+ # @api private
57
+ PRECISION = T.let(1, Integer)
58
+
59
+ # @api private
60
+ MEMBERS = T.let(%i[factor detail contribution].freeze, T::Array[Symbol])
61
+
62
+ sig { returns(Symbol) }
63
+ attr_reader :factor
64
+
65
+ # Written for a person, and it names what was compared rather than which
66
+ # rule fired: "year 1948 matches", not "dob_overlap". A reviewer reading
67
+ # it should not need this library's vocabulary.
68
+ sig { returns(String) }
69
+ attr_reader :detail
70
+
71
+ # Signed, in the same 0..100 units as the score. Positive raises the
72
+ # score and negative lowers it.
73
+ sig { returns(Float) }
74
+ attr_reader :contribution
75
+
76
+ sig { params(hash: T.untyped).returns(T.attached_class) }
77
+ def self.from_h(hash)
78
+ attributes = hash.to_h.transform_keys(&:to_sym)
79
+ unknown = attributes.keys - MEMBERS
80
+ raise InvalidArgument, "unknown Reason attribute(s): #{unknown.join(", ")}" if unknown.any?
81
+
82
+ T.unsafe(self).new(**attributes)
83
+ end
84
+
85
+ sig { params(factor: T.untyped, detail: T.untyped, contribution: T.untyped).void }
86
+ def initialize(factor:, detail:, contribution:)
87
+ @factor = T.let(factor!(factor), Symbol)
88
+ @detail = T.let(detail!(detail), String)
89
+ @contribution = T.let(Float(contribution).round(PRECISION).to_f, Float)
90
+ freeze
91
+ end
92
+
93
+ sig { returns(T::Boolean) }
94
+ def penalty? = contribution.negative?
95
+
96
+ sig { returns(T::Hash[Symbol, T.untyped]) }
97
+ def to_h = { factor: factor, detail: detail, contribution: contribution }
98
+
99
+ # The line a report prints: "+6.0 dob: year 1948 matches".
100
+ sig { returns(String) }
101
+ def to_s = format("%+.#{PRECISION}f %s: %s", contribution, factor, detail)
102
+
103
+ sig { params(other: T.untyped).returns(T::Boolean) }
104
+ def ==(other)
105
+ return false unless other.instance_of?(self.class)
106
+
107
+ to_h == other.to_h
108
+ end
109
+ alias eql? ==
110
+
111
+ sig { returns(Integer) }
112
+ def hash = [self.class, to_h].hash
113
+
114
+ sig { returns(String) }
115
+ def inspect = "#<#{self.class} #{self}>"
116
+
117
+ private
118
+
119
+ sig { params(value: T.untyped).returns(Symbol) }
120
+ def factor!(value)
121
+ symbol = value.to_s.to_sym
122
+ return symbol if FACTORS.include?(symbol)
123
+
124
+ raise InvalidArgument, "unknown factor #{symbol.inspect}, expected one of #{FACTORS.join(", ")}"
125
+ end
126
+
127
+ sig { params(value: T.untyped).returns(String) }
128
+ def detail!(value)
129
+ string = value.to_s.strip
130
+ raise InvalidArgument, "detail is required -- a reason nobody can read is not a reason" if string.empty?
131
+
132
+ -string
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,129 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "active_sanction/scorer/reason"
7
+
8
+ module ActiveSanction
9
+ module Scorer
10
+ # One entity scored against one subject, and the account of how.
11
+ #
12
+ # result = ActiveSanction::Scorer.call(subject, entity)
13
+ #
14
+ # result.score # => 87.4
15
+ # result.entity.id # => "ofac_sdn:2674"
16
+ # result.name.value # => "AERO-CARIBBEAN"
17
+ # result.explanation.map(&:to_s)
18
+ # # => ["+91.2 name: matched alias 'AERO-CARIBBEAN' (aka)",
19
+ # # "+4.0 dob: date of birth 1948 overlaps listed 1948-12-10",
20
+ # # "-8.0 nationality: query RU vs listed EG"]
21
+ #
22
+ # ### The score is the explanation
23
+ #
24
+ # `score` is not stored beside the reasons, it is the sum of them, rounded
25
+ # once. There is no arithmetic anywhere in this library that can move one
26
+ # without the other, which is the point: a compliance officer has to
27
+ # answer "why did this score 87?" to an examiner, and a number that merely
28
+ # travels alongside a list of reasons is a number that can come apart from
29
+ # them in a later release and be wrong quietly for a year.
30
+ #
31
+ # So the explanation is never empty -- the blended name similarity is
32
+ # always the first reason, even when nothing else was known -- and it
33
+ # always adds up.
34
+ #
35
+ # ### `name` is the specific spelling that produced the score
36
+ #
37
+ # An entity's score is the best of its names, and this is the one that
38
+ # won. It matters more than it looks: OFAC ships more aliases than primary
39
+ # names, so the answer to "what did we match?" is usually an alias, and a
40
+ # report that quoted the primary name instead would be describing a
41
+ # comparison that never happened.
42
+ #
43
+ # `form` is that name folded, which is the string the scorers actually
44
+ # compared. Both are here for the reason Normalizer::Form carries both:
45
+ # the published spelling is what a person reads and the folded one is what
46
+ # a person checks.
47
+ #
48
+ # ### What this is not
49
+ #
50
+ # It is not `MatchResult` (#33). This carries what the scorer knows -- a
51
+ # score, a name, an entity, an explanation -- and nothing about the
52
+ # screening run that produced it. The snapshot checksum, the matcher
53
+ # version, the thresholds and the backend all belong to the public API
54
+ # above this one, which is where an audit record is assembled.
55
+ #
56
+ # Instances are frozen on construction and compare by value.
57
+ class Result
58
+ extend T::Sig
59
+
60
+ sig { returns(Entity) }
61
+ attr_reader :entity
62
+
63
+ # The name that scored highest, as its publisher wrote it.
64
+ sig { returns(Name) }
65
+ attr_reader :name
66
+
67
+ # That name folded -- the string the comparison ran on.
68
+ sig { returns(Normalizer::Form) }
69
+ attr_reader :form
70
+
71
+ # 0..100, one decimal place, and equal to the sum of the explanation.
72
+ sig { returns(Float) }
73
+ attr_reader :score
74
+
75
+ # Never empty. See the class comment.
76
+ sig { returns(T::Array[Reason]) }
77
+ attr_reader :explanation
78
+
79
+ sig do
80
+ params(entity: Entity, name: Name, form: Normalizer::Form, explanation: T::Array[Reason])
81
+ .void.checked(:tests)
82
+ end
83
+ def initialize(entity:, name:, form:, explanation:)
84
+ raise InvalidArgument, "a result needs at least one reason" if explanation.empty?
85
+
86
+ @entity = entity
87
+ @name = name
88
+ @form = form
89
+ @explanation = T.let(explanation.dup.freeze, T::Array[Reason])
90
+ @score = T.let(explanation.sum(&:contribution).round(Reason::PRECISION).to_f, Float)
91
+ freeze
92
+ end
93
+
94
+ # The list this entity came from, which every hit has to name.
95
+ sig { returns(Symbol) }
96
+ def source = entity.source
97
+
98
+ # The reasons that lowered the score, which is the half of an
99
+ # explanation a reviewer clearing an alert reads first.
100
+ sig { returns(T::Array[Reason]) }
101
+ def penalties = explanation.select(&:penalty?)
102
+
103
+ sig { returns(T::Hash[Symbol, T.untyped]) }
104
+ def to_h
105
+ {
106
+ score: score,
107
+ entity_id: entity.id,
108
+ source: source,
109
+ name: name.to_h,
110
+ explanation: explanation.map(&:to_h)
111
+ }
112
+ end
113
+
114
+ sig { params(other: T.untyped).returns(T::Boolean) }
115
+ def ==(other)
116
+ return false unless other.instance_of?(self.class)
117
+
118
+ entity == other.entity && name == other.name && explanation == other.explanation
119
+ end
120
+ alias eql? ==
121
+
122
+ sig { returns(Integer) }
123
+ def hash = [self.class, entity, name, explanation].hash
124
+
125
+ sig { returns(String) }
126
+ def inspect = "#<#{self.class} #{score} #{name.value.inspect} (#{entity.id})>"
127
+ end
128
+ end
129
+ end