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,235 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "active_sanction/country"
7
+ require "active_sanction/entity"
8
+ require "active_sanction/identifier"
9
+ require "active_sanction/normalizer"
10
+ require "active_sanction/partial_date"
11
+
12
+ module ActiveSanction
13
+ module Scorer
14
+ # What the caller knows about the person or company being screened.
15
+ #
16
+ # subject = ActiveSanction::Scorer::Subject.new(
17
+ # name: "Bosco Ntaganda",
18
+ # type: :individual,
19
+ # dates_of_birth: "1973",
20
+ # nationalities: %w[CD],
21
+ # identifiers: [{ kind: :passport, value: "AB-123 456" }]
22
+ # )
23
+ #
24
+ # subject.form.value # => "bosco ntaganda"
25
+ #
26
+ # One side of every comparison the scorer makes, and the mirror image of
27
+ # the Entity on the other side: the same four kinds of evidence, arriving
28
+ # from an application's own customer record rather than from a government.
29
+ #
30
+ # ### Only `name` is required, and that is the shape of the problem
31
+ #
32
+ # Most callers have a name and little else, and most records carry less
33
+ # than that -- Canada supplies no aliases and often no date of birth. So
34
+ # every field but the name is optional on both sides, and a field absent
35
+ # on either side is neutral rather than a conflict. See Adjustments, where
36
+ # that rule is the difference between a screening tool and a tool that
37
+ # systematically under-scores the sparser lists.
38
+ #
39
+ # ### `type` decides two things
40
+ #
41
+ # It is the entity type the caller is asking about, and it does two jobs
42
+ # that are easy to confuse. It selects the normalizer's stoplists, so a
43
+ # company name is folded with its legal form stripped -- and both sides of
44
+ # a comparison have to be folded the same way, which is why the scorer
45
+ # folds each candidate name under its own entity's type. And it filters:
46
+ # a subject that says `:individual` is never scored against a vessel, at
47
+ # any name similarity. See Scorer.
48
+ #
49
+ # Passing no type is a legitimate answer and a different question -- no
50
+ # stoplist, no filter -- rather than a worse one.
51
+ #
52
+ # ### The fold happens once, here
53
+ #
54
+ # `form` is the folded name, produced by the one `Normalizer.call` every
55
+ # other stage uses, and held for the life of the subject. A screening call
56
+ # compares one subject against a few hundred candidates, and folding the
57
+ # query per candidate would be the same string folded a few hundred times.
58
+ #
59
+ # A caller that has already folded a name passes the Form, which is what
60
+ # screening one name against several indexes should do.
61
+ #
62
+ # ### Where Query fits
63
+ #
64
+ # This is the scorer's input, not the library's public screening API. The
65
+ # `Query` object (#33) validates what a host application sends -- a
66
+ # threshold, a limit, a source filter -- and builds one of these for the
67
+ # matcher to score with. Everything on this class is evidence about a
68
+ # subject; nothing on it is a search option.
69
+ #
70
+ # Instances are frozen on construction and compare by value.
71
+ class Subject
72
+ extend T::Sig
73
+
74
+ # @api private
75
+ MEMBERS = T.let(%i[name type dates_of_birth nationalities identifiers].freeze, T::Array[Symbol])
76
+
77
+ # The folded name every comparison runs against.
78
+ sig { returns(Normalizer::Form) }
79
+ attr_reader :form
80
+
81
+ sig { returns(T.nilable(Symbol)) }
82
+ attr_reader :type
83
+
84
+ sig { returns(T::Array[PartialDate]) }
85
+ attr_reader :dates_of_birth
86
+
87
+ # As published by the caller, in the caller's own vocabulary: `RU`,
88
+ # `Russia` and `Russian Federation` are all fine here. See #countries
89
+ # for the resolved form the scorer compares on.
90
+ sig { returns(T::Array[String]) }
91
+ attr_reader :nationalities
92
+
93
+ sig { returns(T::Array[Identifier]) }
94
+ attr_reader :identifiers
95
+
96
+ sig { params(hash: T.untyped).returns(T.attached_class) }
97
+ def self.from_h(hash)
98
+ attributes = hash.to_h.transform_keys(&:to_sym)
99
+ unknown = attributes.keys - MEMBERS
100
+ raise InvalidArgument, "unknown Subject attribute(s): #{unknown.join(", ")}" if unknown.any?
101
+
102
+ T.unsafe(self).new(**attributes)
103
+ end
104
+
105
+ # `name` is a String, a Name or an already-folded Form. The three
106
+ # collections each accept a single value as a collection of one, since
107
+ # `dates_of_birth: "1973"` is what a caller with one date writes.
108
+ #
109
+ # Dates accept anything PartialDate reads, including the free text these
110
+ # lists publish; identifiers accept an Identifier, its hash, or a bare
111
+ # document number, which becomes an identifier of unstated kind.
112
+ sig do
113
+ params(name: T.untyped, type: T.untyped, dates_of_birth: T.untyped, nationalities: T.untyped,
114
+ identifiers: T.untyped).void
115
+ end
116
+ def initialize(name:, type: nil, dates_of_birth: [], nationalities: [], identifiers: [])
117
+ @type = T.let(type!(type), T.nilable(Symbol))
118
+ @form = T.let(form!(name), Normalizer::Form)
119
+ @dates_of_birth = T.let(Array(dates_of_birth).map { |value| date!(value) }.freeze, T::Array[PartialDate])
120
+ @nationalities = T.let(strings(nationalities), T::Array[String])
121
+ @identifiers = T.let(Array(identifiers).map { |value| identifier!(value) }.freeze, T::Array[Identifier])
122
+ @countries = T.let(resolve(@nationalities), T::Array[String])
123
+ freeze
124
+ end
125
+
126
+ # The name as the caller wrote it, which is what a report quotes back.
127
+ sig { returns(String) }
128
+ def name = form.original
129
+
130
+ # The alpha-2 codes #nationalities resolved to, which may be shorter
131
+ # than the list it came from: a value Country does not recognize is
132
+ # dropped here rather than guessed at, and the scorer treats a subject
133
+ # whose countries did not all resolve as one that cannot contradict a
134
+ # record. See Adjustments.
135
+ sig { returns(T::Array[String]) }
136
+ attr_reader :countries
137
+
138
+ # True when every nationality the caller gave resolved to a country.
139
+ # A conflict penalty is only applied when both sides can say this.
140
+ sig { returns(T::Boolean) }
141
+ def countries? = nationalities.any? && countries.size == nationalities.uniq.size
142
+
143
+ sig { returns(T::Boolean) }
144
+ def dates_of_birth? = dates_of_birth.any?
145
+
146
+ sig { returns(T::Boolean) }
147
+ def identifiers? = identifiers.any?
148
+
149
+ sig { returns(T::Hash[Symbol, T.untyped]) }
150
+ def to_h
151
+ {
152
+ name: name,
153
+ type: type,
154
+ dates_of_birth: dates_of_birth.map(&:to_h),
155
+ nationalities: nationalities,
156
+ identifiers: identifiers.map(&:to_h)
157
+ }
158
+ end
159
+
160
+ sig { params(other: T.untyped).returns(T::Boolean) }
161
+ def ==(other)
162
+ return false unless other.instance_of?(self.class)
163
+
164
+ to_h == other.to_h
165
+ end
166
+ alias eql? ==
167
+
168
+ sig { returns(Integer) }
169
+ def hash = [self.class, to_h].hash
170
+
171
+ sig { returns(String) }
172
+ def inspect = "#<#{self.class} #{name.inspect}#{" type=#{type.inspect}" if type}>"
173
+
174
+ private
175
+
176
+ # A Form is taken as it stands, and it is the caller's job to have
177
+ # folded it under the same type -- there is no way to check, since a
178
+ # Form does not record which stoplist produced it, and re-folding it
179
+ # here would silently discard the caller's intent. See Normalizer on
180
+ # why one fold, once, is the whole point.
181
+ sig { params(value: T.untyped).returns(Normalizer::Form) }
182
+ def form!(value)
183
+ return value if value.is_a?(Normalizer::Form)
184
+
185
+ string = value.to_s.strip
186
+ raise InvalidArgument, "name is required -- there is nothing to screen without one" if string.empty?
187
+
188
+ folded = Normalizer.call(string, type: type)
189
+ raise InvalidArgument, "name folds away to nothing: #{string.inspect}" if folded.empty?
190
+
191
+ folded
192
+ end
193
+
194
+ sig { params(value: T.untyped).returns(T.nilable(Symbol)) }
195
+ def type!(value)
196
+ return nil if value.nil? || value.to_s.empty?
197
+
198
+ symbol = value.to_s.downcase.to_sym
199
+ return symbol if Entity::TYPES.include?(symbol)
200
+
201
+ raise InvalidArgument, "unknown type #{symbol.inspect}, expected one of #{Entity::TYPES.join(", ")} or nil"
202
+ end
203
+
204
+ sig { params(value: T.untyped).returns(PartialDate) }
205
+ def date!(value)
206
+ case value
207
+ when PartialDate then value
208
+ when Hash then PartialDate.from_h(value)
209
+ else PartialDate.parse(value) || raise(InvalidArgument, "not a date of birth: #{value.inspect}")
210
+ end
211
+ end
212
+
213
+ sig { params(value: T.untyped).returns(Identifier) }
214
+ def identifier!(value)
215
+ case value
216
+ when Identifier then value
217
+ when Hash then Identifier.from_h(value)
218
+ else Identifier.new(value: value)
219
+ end
220
+ end
221
+
222
+ # A value Country does not recognize is dropped rather than guessed at.
223
+ # #countries? is how a caller tells a fully resolved list from a partly
224
+ # resolved one, which is the difference between a nationality that may
225
+ # contradict a record and one that may only agree with it.
226
+ sig { params(values: T::Array[String]).returns(T::Array[String]) }
227
+ def resolve(values) = values.filter_map { |value| Country.code(value) }.uniq.freeze
228
+
229
+ sig { params(value: T.untyped).returns(T::Array[String]) }
230
+ def strings(value)
231
+ Array(value).map { |entry| -entry.to_s.strip }.reject(&:empty?).uniq.freeze
232
+ end
233
+ end
234
+ end
235
+ end
@@ -0,0 +1,306 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module ActiveSanction
7
+ module Scorer
8
+ # Every number the scorer uses, with a default and a reason for it.
9
+ #
10
+ # ActiveSanction::Scorer::Weights.default.token_set # => 0.45
11
+ # ActiveSanction::Scorer::Weights.default.dob_conflict # => -35.0
12
+ #
13
+ # ActiveSanction.configure do |c|
14
+ # c.scorer_weights = { dob_conflict: -20.0 }
15
+ # end
16
+ #
17
+ # There are two kinds of number here and they are in different units,
18
+ # which is the thing to know before changing one.
19
+ #
20
+ # ### The name shares, which are fractions of the name score
21
+ #
22
+ # `jaro_winkler`, `levenshtein`, `token_sort`, `token_set` and `phonetic`
23
+ # are shares of one blended similarity. They are each between 0 and 1 and
24
+ # they must sum to exactly 1, which is what makes the name score a
25
+ # percentage rather than an arbitrary total: two identical names score
26
+ # 100 because every share agreed, and a share that agreed on nothing
27
+ # contributes nothing.
28
+ #
29
+ # The defaults are set from what the four algorithms disagree about --
30
+ # Similarity's own table is the argument, and it is worth reading beside
31
+ # this:
32
+ #
33
+ # JW Lev Sort Set Phon blend
34
+ # abbas abu / abu abbas 0.805 0.333 1.000 1.000 1.000 90.4
35
+ # putin vladimir vladimirovich /
36
+ # vladimir putin 0.679 0.357 0.500 1.000 1.000 76.2
37
+ # gazprom / gazprom neft 0.917 0.583 0.583 1.000 1.000 84.2
38
+ # kim jong un / kim yong chol 0.869 0.615 0.462 0.462 0.667 54.8
39
+ #
40
+ # **The token ratios carry most of the weight**, because the query shapes
41
+ # this corpus actually produces are rearrangements. An individual is
42
+ # published surname-first and typed given-name-first; a patronymic is on
43
+ # the record and not in the query. Row one and row two are both true
44
+ # matches that the character algorithms score in the sixties and the
45
+ # seventies, and a blend that let them decide would miss the two most
46
+ # common true positives there are.
47
+ #
48
+ # **`token_set` is the largest single share** because a 1.0 from it means
49
+ # something specific and strong: every word of the shorter name appears in
50
+ # the longer one. That is the shape of nearly every honest partial query.
51
+ #
52
+ # **The character algorithms are the brake.** Row four is the case they
53
+ # exist for -- two names already written in the same order, where sorting
54
+ # loses the information that they are, and the token ratios happily score
55
+ # a different person at 0.462. Levenshtein's share is the smallest because
56
+ # it is the harshest measure in the set: on a name that is one token
57
+ # longer it is already down in the fifties, and giving it more would pull
58
+ # every true partial match down with it.
59
+ #
60
+ # **The phonetic share is small and it is a share, not a bonus.** A shared
61
+ # Double Metaphone key is real evidence -- it is what puts `QADHAFI` and
62
+ # `GADDAFI` together -- and it is weak evidence, because `HSN` is the key
63
+ # for `HUSSEIN` and equally for `HASSAN`. Five points is what it is worth
64
+ # on its own; the reason it is inside the sum rather than added on top is
65
+ # that a bonus would put an identical pair over 100 and need clamping to
66
+ # get back, and a score that reaches its ceiling by two different routes
67
+ # is one nobody can reason about.
68
+ #
69
+ # ### The adjustments, which are points on the 0..100 score
70
+ #
71
+ # These are added to the name score, not multiplied into it, because they
72
+ # are separate evidence rather than a re-reading of the name. A passport
73
+ # number is not "more name"; it is the thing that makes a mediocre name
74
+ # match decisive, and a boost that scaled with the name score could not do
75
+ # that.
76
+ #
77
+ # `identifier_match` is 40 and is meant to be decisive: a name in the
78
+ # fifties plus the right passport number clears any sane threshold, which
79
+ # is the entire reason a screening tool asks for document numbers.
80
+ #
81
+ # `dob_conflict` at -35 is the one number the acceptance criteria pin
82
+ # down. A name-identical pair scores 100, and a genuine date-of-birth
83
+ # conflict has to put it under the threshold rather than merely rank it
84
+ # lower -- 65 is under every default this library ships. The exact match
85
+ # is worth less than the conflict costs on purpose: sharing a birthday
86
+ # with a listed person is a coincidence a few thousand people have, and
87
+ # not having theirs is not.
88
+ #
89
+ # `dob_overlap` at 6 is what a year-only date is worth. Most of these
90
+ # records carry one -- see PartialDate on why the type exists -- and
91
+ # `1948` against `1948-12-10` is agreement worth noting and not worth much.
92
+ #
93
+ # Nationality moves the score least in both directions, because it is the
94
+ # softest of the three. People hold two passports, lists record the
95
+ # country a person was born in as often as the one they are a citizen of,
96
+ # and a conflict there is weaker evidence than a date conflict by some way.
97
+ #
98
+ # ### Penalties are stored negative
99
+ #
100
+ # `dob_conflict` is `-35.0` rather than `35.0` subtracted somewhere else,
101
+ # so that a Reason's contribution is the number in this object and a host
102
+ # reading a configuration can see which way each one pushes. A boost
103
+ # written negative, or a penalty written positive, is refused.
104
+ #
105
+ # Instances are frozen on construction and compare by value.
106
+ class Weights
107
+ extend T::Sig
108
+
109
+ # The five shares of the blended name score. They sum to 1.
110
+ #
111
+ # @api private
112
+ NAME_SHARES = T.let(%i[jaro_winkler levenshtein token_sort token_set phonetic].freeze, T::Array[Symbol])
113
+
114
+ # Points added to the name score. Anything listed in PENALTIES must be
115
+ # zero or negative; everything else here must be zero or positive.
116
+ #
117
+ # @api private
118
+ ADJUSTMENTS = T.let(%i[
119
+ low_quality_alias identifier_match dob_exact dob_overlap dob_conflict
120
+ nationality_match nationality_conflict
121
+ ].freeze, T::Array[Symbol])
122
+
123
+ # @api private
124
+ PENALTIES = T.let(%i[low_quality_alias dob_conflict nationality_conflict].freeze, T::Array[Symbol])
125
+
126
+ # @api private
127
+ MEMBERS = T.let((NAME_SHARES + ADJUSTMENTS).freeze, T::Array[Symbol])
128
+
129
+ # Every weight this library ships, and the numbers the committed
130
+ # accuracy report was measured with. A host tuning one starts from
131
+ # here, changes what it means to and leaves the rest alone -- the
132
+ # shares are held to summing to 1.0, so they move in pairs.
133
+ DEFAULTS = T.let({
134
+ jaro_winkler: 0.15,
135
+ levenshtein: 0.10,
136
+ token_sort: 0.25,
137
+ token_set: 0.45,
138
+ phonetic: 0.05,
139
+ low_quality_alias: -10.0,
140
+ identifier_match: 40.0,
141
+ dob_exact: 15.0,
142
+ dob_overlap: 6.0,
143
+ dob_conflict: -35.0,
144
+ nationality_match: 6.0,
145
+ nationality_conflict: -12.0
146
+ }.freeze, T::Hash[Symbol, Float])
147
+
148
+ # Floating point addition of five decimal fractions does not land on 1.0
149
+ # exactly, and refusing a set of shares over the last bit of a Float
150
+ # would be refusing arithmetic rather than a misconfiguration.
151
+ #
152
+ # @api private
153
+ SHARE_TOLERANCE = T.let(1e-9, Float)
154
+
155
+ # Spelled out rather than defined from MEMBERS in a loop, because a
156
+ # reader Sorbet cannot see is a reader every call site has to be
157
+ # `T.unsafe` to reach. Each one reads the hash; nothing here is stored
158
+ # twice.
159
+ sig { returns(Float) }
160
+ def jaro_winkler = fetch(:jaro_winkler)
161
+
162
+ sig { returns(Float) }
163
+ def levenshtein = fetch(:levenshtein)
164
+
165
+ sig { returns(Float) }
166
+ def token_sort = fetch(:token_sort)
167
+
168
+ sig { returns(Float) }
169
+ def token_set = fetch(:token_set)
170
+
171
+ sig { returns(Float) }
172
+ def phonetic = fetch(:phonetic)
173
+
174
+ sig { returns(Float) }
175
+ def low_quality_alias = fetch(:low_quality_alias)
176
+
177
+ sig { returns(Float) }
178
+ def identifier_match = fetch(:identifier_match)
179
+
180
+ sig { returns(Float) }
181
+ def dob_exact = fetch(:dob_exact)
182
+
183
+ sig { returns(Float) }
184
+ def dob_overlap = fetch(:dob_overlap)
185
+
186
+ sig { returns(Float) }
187
+ def dob_conflict = fetch(:dob_conflict)
188
+
189
+ sig { returns(Float) }
190
+ def nationality_match = fetch(:nationality_match)
191
+
192
+ sig { returns(Float) }
193
+ def nationality_conflict = fetch(:nationality_conflict)
194
+
195
+ class << self
196
+ extend T::Sig
197
+
198
+ # The shipped numbers. Built at load, so nothing has to synchronize
199
+ # its construction.
200
+ sig { returns(Weights) }
201
+ def default = DEFAULT
202
+
203
+ # The shipped numbers with some replaced, which is what a host almost
204
+ # always wants:
205
+ #
206
+ # Weights.build(dob_conflict: -20.0)
207
+ #
208
+ # A Weights passes through, so a caller holding either can hand this
209
+ # whatever it has.
210
+ sig { params(value: T.untyped).returns(Weights) }
211
+ def build(value)
212
+ return default if value.nil?
213
+ return value if value.is_a?(Weights)
214
+ raise InvalidArgument, "expected a #{self} or a Hash of weights, got #{value.class}" unless value.is_a?(Hash)
215
+
216
+ T.unsafe(default).merge(**value.to_h { |member, weight| [member.to_s.to_sym, weight] })
217
+ end
218
+ end
219
+
220
+ # Every member defaults, so `new` and `new(dob_conflict: -20.0)` are
221
+ # both a complete set. Unlike the normalizer's dictionaries there is no
222
+ # danger in a partial replacement here: a number left out is the shipped
223
+ # one, and the shares are checked to sum to 1 whatever a caller passed.
224
+ sig { params(overrides: T.untyped).void }
225
+ def initialize(**overrides)
226
+ unknown = overrides.keys - MEMBERS
227
+ raise InvalidArgument, "unknown weight(s): #{unknown.join(", ")}" if unknown.any?
228
+
229
+ @weights = T.let(DEFAULTS.merge(overrides).to_h { |member, weight| [member, number!(member, weight)] }.freeze,
230
+ T::Hash[Symbol, Float])
231
+ validate_shares!
232
+ freeze
233
+ end
234
+
235
+ # These weights with some replaced.
236
+ sig { params(overrides: T.untyped).returns(Weights) }
237
+ def merge(**overrides) = T.unsafe(self.class).new(**@weights, **overrides)
238
+
239
+ sig { params(member: Symbol).returns(Float) }
240
+ def fetch(member) = @weights.fetch(member)
241
+
242
+ sig { returns(T::Hash[Symbol, Float]) }
243
+ def to_h = @weights.dup
244
+
245
+ sig { params(other: T.untyped).returns(T::Boolean) }
246
+ def ==(other)
247
+ return false unless other.instance_of?(self.class)
248
+
249
+ to_h == other.to_h
250
+ end
251
+ alias eql? ==
252
+
253
+ sig { returns(Integer) }
254
+ def hash = [self.class, to_h].hash
255
+
256
+ sig { returns(String) }
257
+ def inspect = "#<#{self.class} #{to_h.map { |member, weight| "#{member}=#{weight}" }.join(" ")}>"
258
+
259
+ private
260
+
261
+ sig { params(member: Symbol, value: T.untyped).returns(Float) }
262
+ def number!(member, value)
263
+ number = begin
264
+ Float(value)
265
+ rescue TypeError, ArgumentError
266
+ raise InvalidArgument, "#{member} must be a number, got #{value.inspect}"
267
+ end
268
+ direction!(member, number)
269
+ number
270
+ end
271
+
272
+ # A boost written negative is a configuration that quietly inverts a
273
+ # signal -- a passport match that lowers a score -- and it would look
274
+ # exactly like a scorer bug from outside.
275
+ sig { params(member: Symbol, number: Float).void }
276
+ def direction!(member, number)
277
+ if PENALTIES.include?(member)
278
+ raise InvalidArgument, "#{member} is a penalty and cannot be positive, got #{number}" if number.positive?
279
+ elsif number.negative?
280
+ raise InvalidArgument, "#{member} is a boost and cannot be negative, got #{number}"
281
+ end
282
+ end
283
+
284
+ sig { void }
285
+ def validate_shares!
286
+ NAME_SHARES.each do |share|
287
+ weight = @weights.fetch(share)
288
+ raise InvalidArgument, "#{share} must be between 0 and 1, got #{weight}" unless weight.between?(0.0, 1.0)
289
+ end
290
+ total = NAME_SHARES.sum { |share| @weights.fetch(share) }
291
+ return if (total - 1.0).abs <= SHARE_TOLERANCE
292
+
293
+ raise InvalidArgument,
294
+ "the name shares must sum to 1.0, got #{total.round(6)} -- " \
295
+ "#{NAME_SHARES.map { |share| "#{share}=#{@weights.fetch(share)}" }.join(", ")}"
296
+ end
297
+
298
+ # Last, because building it runs #initialize, which calls every private
299
+ # method above.
300
+ #
301
+ # @api private
302
+ DEFAULT = T.let(new, Weights)
303
+ private_constant :DEFAULT
304
+ end
305
+ end
306
+ end