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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +538 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/CONTRIBUTING.md +312 -0
- data/LICENSE.txt +21 -0
- data/README.md +616 -0
- data/SECURITY.md +97 -0
- data/active_sanction.gemspec +98 -0
- data/docs/adding_a_source.md +1083 -0
- data/docs/api_stability.md +357 -0
- data/docs/bundle_format.md +407 -0
- data/lib/active_sanction/address.rb +170 -0
- data/lib/active_sanction/client.rb +341 -0
- data/lib/active_sanction/configuration.rb +721 -0
- data/lib/active_sanction/countries.txt +292 -0
- data/lib/active_sanction/country.rb +164 -0
- data/lib/active_sanction/deprecation.rb +168 -0
- data/lib/active_sanction/diff/change.rb +208 -0
- data/lib/active_sanction/diff.rb +348 -0
- data/lib/active_sanction/doctor/checkup.rb +356 -0
- data/lib/active_sanction/doctor/diagnosis.rb +245 -0
- data/lib/active_sanction/doctor/finding.rb +183 -0
- data/lib/active_sanction/doctor/profile.rb +384 -0
- data/lib/active_sanction/doctor/report.rb +227 -0
- data/lib/active_sanction/doctor.rb +347 -0
- data/lib/active_sanction/entity.rb +293 -0
- data/lib/active_sanction/error.rb +332 -0
- data/lib/active_sanction/fetcher/result.rb +116 -0
- data/lib/active_sanction/fetcher.rb +242 -0
- data/lib/active_sanction/http_client/errors.rb +94 -0
- data/lib/active_sanction/http_client/response.rb +129 -0
- data/lib/active_sanction/http_client.rb +324 -0
- data/lib/active_sanction/identifier.rb +212 -0
- data/lib/active_sanction/index/builder.rb +89 -0
- data/lib/active_sanction/index/candidate.rb +63 -0
- data/lib/active_sanction/index/entry.rb +66 -0
- data/lib/active_sanction/index/features.rb +112 -0
- data/lib/active_sanction/index.rb +381 -0
- data/lib/active_sanction/match_result.rb +345 -0
- data/lib/active_sanction/matcher.rb +388 -0
- data/lib/active_sanction/name.rb +183 -0
- data/lib/active_sanction/normalizer/cache.rb +82 -0
- data/lib/active_sanction/normalizer/dictionaries/honorifics.txt +30 -0
- data/lib/active_sanction/normalizer/dictionaries/legal_forms.txt +72 -0
- data/lib/active_sanction/normalizer/dictionaries/organization_stopwords.txt +20 -0
- data/lib/active_sanction/normalizer/dictionaries/particles.txt +35 -0
- data/lib/active_sanction/normalizer/dictionary/stoplist.rb +121 -0
- data/lib/active_sanction/normalizer/dictionary.rb +243 -0
- data/lib/active_sanction/normalizer/form.rb +279 -0
- data/lib/active_sanction/normalizer.rb +137 -0
- data/lib/active_sanction/parsers/column_shape.rb +274 -0
- data/lib/active_sanction/parsers/delimited_table/reader.rb +194 -0
- data/lib/active_sanction/parsers/delimited_table/row.rb +81 -0
- data/lib/active_sanction/parsers/delimited_table.rb +151 -0
- data/lib/active_sanction/parsers/format.rb +107 -0
- data/lib/active_sanction/parsers/join.rb +149 -0
- data/lib/active_sanction/parsers/spreadsheet/archive.rb +258 -0
- data/lib/active_sanction/parsers/spreadsheet/reader.rb +213 -0
- data/lib/active_sanction/parsers/spreadsheet/row.rb +88 -0
- data/lib/active_sanction/parsers/spreadsheet/workbook.rb +314 -0
- data/lib/active_sanction/parsers/spreadsheet.rb +168 -0
- data/lib/active_sanction/parsers/xml_records/backends/nokogiri.rb +134 -0
- data/lib/active_sanction/parsers/xml_records/backends/rexml.rb +157 -0
- data/lib/active_sanction/parsers/xml_records/backends.rb +103 -0
- data/lib/active_sanction/parsers/xml_records/builder.rb +90 -0
- data/lib/active_sanction/parsers/xml_records/reader.rb +134 -0
- data/lib/active_sanction/parsers/xml_records/record.rb +174 -0
- data/lib/active_sanction/parsers/xml_records.rb +140 -0
- data/lib/active_sanction/parsers.rb +94 -0
- data/lib/active_sanction/partial_date/parser.rb +124 -0
- data/lib/active_sanction/partial_date.rb +303 -0
- data/lib/active_sanction/payload_cache/checksum.rb +66 -0
- data/lib/active_sanction/payload_cache/entry.rb +304 -0
- data/lib/active_sanction/payload_cache.rb +366 -0
- data/lib/active_sanction/phonetics/double_metaphone.rb +662 -0
- data/lib/active_sanction/phonetics.rb +40 -0
- data/lib/active_sanction/query.rb +290 -0
- data/lib/active_sanction/rescreen/alert.rb +314 -0
- data/lib/active_sanction/rescreen.rb +332 -0
- data/lib/active_sanction/scorer/adjustments.rb +241 -0
- data/lib/active_sanction/scorer/name_score.rb +279 -0
- data/lib/active_sanction/scorer/reason.rb +136 -0
- data/lib/active_sanction/scorer/result.rb +129 -0
- data/lib/active_sanction/scorer/subject.rb +235 -0
- data/lib/active_sanction/scorer/weights.rb +306 -0
- data/lib/active_sanction/scorer.rb +303 -0
- data/lib/active_sanction/similarity/jaro_winkler.rb +210 -0
- data/lib/active_sanction/similarity/levenshtein.rb +179 -0
- data/lib/active_sanction/similarity/token_set.rb +159 -0
- data/lib/active_sanction/similarity/token_sort.rb +120 -0
- data/lib/active_sanction/similarity.rb +183 -0
- data/lib/active_sanction/snapshot/bundle/header.rb +302 -0
- data/lib/active_sanction/snapshot/bundle/payload.rb +185 -0
- data/lib/active_sanction/snapshot/bundle/signature.rb +201 -0
- data/lib/active_sanction/snapshot/bundle.rb +381 -0
- data/lib/active_sanction/snapshot.rb +305 -0
- data/lib/active_sanction/sources/australia_dfat/published_date.rb +166 -0
- data/lib/active_sanction/sources/australia_dfat/record.rb +319 -0
- data/lib/active_sanction/sources/australia_dfat.rb +291 -0
- data/lib/active_sanction/sources/base.rb +295 -0
- data/lib/active_sanction/sources/canada_sema/record.rb +282 -0
- data/lib/active_sanction/sources/canada_sema/source_ref.rb +89 -0
- data/lib/active_sanction/sources/canada_sema.rb +153 -0
- data/lib/active_sanction/sources/definition.rb +360 -0
- data/lib/active_sanction/sources/eu_fsf/record.rb +467 -0
- data/lib/active_sanction/sources/eu_fsf.rb +203 -0
- data/lib/active_sanction/sources/ofac/record.rb +251 -0
- data/lib/active_sanction/sources/ofac/remarks_parser/coverage.rb +120 -0
- data/lib/active_sanction/sources/ofac/remarks_parser/vocabulary.rb +121 -0
- data/lib/active_sanction/sources/ofac/remarks_parser.rb +312 -0
- data/lib/active_sanction/sources/ofac.rb +248 -0
- data/lib/active_sanction/sources/ofac_consolidated/record.rb +37 -0
- data/lib/active_sanction/sources/ofac_consolidated.rb +203 -0
- data/lib/active_sanction/sources/ofac_sdn.rb +43 -0
- data/lib/active_sanction/sources/remarks.rb +84 -0
- data/lib/active_sanction/sources/uk_sanctions_list/published_date.rb +104 -0
- data/lib/active_sanction/sources/uk_sanctions_list/record.rb +412 -0
- data/lib/active_sanction/sources/uk_sanctions_list.rb +214 -0
- data/lib/active_sanction/sources/un_consolidated/record.rb +279 -0
- data/lib/active_sanction/sources/un_consolidated.rb +140 -0
- data/lib/active_sanction/sources.rb +196 -0
- data/lib/active_sanction/storage/active_record/reader.rb +124 -0
- data/lib/active_sanction/storage/active_record/row.rb +175 -0
- data/lib/active_sanction/storage/active_record/writer.rb +141 -0
- data/lib/active_sanction/storage/active_record.rb +277 -0
- data/lib/active_sanction/storage/base.rb +216 -0
- data/lib/active_sanction/storage/file_system.rb +373 -0
- data/lib/active_sanction/storage/memory.rb +67 -0
- data/lib/active_sanction/storage/meta.rb +188 -0
- data/lib/active_sanction/storage.rb +74 -0
- data/lib/active_sanction/subject.rb +271 -0
- data/lib/active_sanction/sync/report.rb +245 -0
- data/lib/active_sanction/sync/result.rb +296 -0
- data/lib/active_sanction/sync.rb +390 -0
- data/lib/active_sanction/validator_store/file_system.rb +103 -0
- data/lib/active_sanction/validator_store/memory.rb +41 -0
- data/lib/active_sanction/validator_store.rb +119 -0
- data/lib/active_sanction/validators.rb +195 -0
- data/lib/active_sanction/version.rb +23 -0
- data/lib/active_sanction.rb +331 -0
- data/lib/generators/active_sanction/install/install_generator.rb +60 -0
- data/lib/generators/active_sanction/install/templates/create_active_sanction_tables.rb.tt +142 -0
- metadata +236 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "time"
|
|
7
|
+
require "active_sanction/entity"
|
|
8
|
+
require "active_sanction/name"
|
|
9
|
+
require "active_sanction/query"
|
|
10
|
+
require "active_sanction/version"
|
|
11
|
+
require "active_sanction/scorer/reason"
|
|
12
|
+
require "active_sanction/scorer/weights"
|
|
13
|
+
|
|
14
|
+
module ActiveSanction
|
|
15
|
+
# One hit, and everything needed to defend it years later.
|
|
16
|
+
#
|
|
17
|
+
# result = ActiveSanction.screen(name: "Bosco Ntaganda", type: :individual).first
|
|
18
|
+
#
|
|
19
|
+
# result.score # => 100.0
|
|
20
|
+
# result.entity.id # => "un_consolidated:6908021"
|
|
21
|
+
# result.matched_name # => the specific Name that produced the score
|
|
22
|
+
# result.source # => :un_consolidated
|
|
23
|
+
# result.explanation # => [Reason, ...], summing to the score
|
|
24
|
+
# result.snapshot_id # => "sha256:9f86d081884c7d65..."
|
|
25
|
+
# result.matcher_version # => "1"
|
|
26
|
+
# result.verified? # => false, unless the list came from a signed bundle
|
|
27
|
+
# result.screened_at # => 2026-09-06 11:04:02 UTC
|
|
28
|
+
#
|
|
29
|
+
# ### This is the most permanent object in the library
|
|
30
|
+
#
|
|
31
|
+
# Everything else here is a step in a pipeline. This is what leaves the
|
|
32
|
+
# library and goes into a customer's audit record, and it is read by people
|
|
33
|
+
# who do not have this process, this configuration, or this version of the
|
|
34
|
+
# gem -- an examiner asking in 2029 why a payment was cleared in 2026. So it
|
|
35
|
+
# serializes to a documented shape, `from_h` rebuilds it losslessly from
|
|
36
|
+
# that shape, and every field that could have changed the answer is on it.
|
|
37
|
+
#
|
|
38
|
+
# ### The reproducibility stamp
|
|
39
|
+
#
|
|
40
|
+
# Four fields make a past decision re-derivable, and each of them is a way
|
|
41
|
+
# the same query could score differently today:
|
|
42
|
+
#
|
|
43
|
+
# - **`snapshot_id`** -- the checksum (#8) of the exact list version that
|
|
44
|
+
# answered, for the source this hit came from. Publishers overwrite their
|
|
45
|
+
# files in place, so "the OFAC list" is not a thing that can be cited; a
|
|
46
|
+
# checksum is.
|
|
47
|
+
# - **`matcher_version`** -- see Matcher::VERSION. Which pipeline scored it.
|
|
48
|
+
# - **`weights`** -- what each signal was worth. A host that retunes
|
|
49
|
+
# `dob_conflict` changes what every past decision would score today, and a
|
|
50
|
+
# record that did not say which numbers it was made under could not be
|
|
51
|
+
# told apart from one that would score the same.
|
|
52
|
+
# - **`query`** -- what was screened, and under what threshold and limit. A
|
|
53
|
+
# record that says what was found but not what was asked is half an
|
|
54
|
+
# answer: a hit at 78 means one thing under a threshold of 75 and cannot
|
|
55
|
+
# have existed under 85.
|
|
56
|
+
#
|
|
57
|
+
# `backend` is the fifth, and it is here for #56: a hosted backend answers
|
|
58
|
+
# the same call against data somebody else keeps fresh, and an audit record
|
|
59
|
+
# has to say which one answered.
|
|
60
|
+
#
|
|
61
|
+
# `verified` is the sixth, and it is the only one about *provenance* rather
|
|
62
|
+
# than about scoring: whether the list this hit came off was a signed bundle
|
|
63
|
+
# (#57) that checked out under a key this installation holds. See #verified?.
|
|
64
|
+
#
|
|
65
|
+
# ### Constructible without the local scorer, on purpose
|
|
66
|
+
#
|
|
67
|
+
# `.from_scorer` is the convenience the Local backend uses. `.new` takes
|
|
68
|
+
# every field outright, and that is the seam: a hosted backend deserializes
|
|
69
|
+
# what a service returned and builds one of these directly. If this were
|
|
70
|
+
# constructible only from a Scorer::Result, the two products would already
|
|
71
|
+
# have two result types.
|
|
72
|
+
#
|
|
73
|
+
# What both routes are held to is the invariant the explanation carries --
|
|
74
|
+
# `score` is the sum of the reasons, rounded once, and a `score:` passed in
|
|
75
|
+
# that disagrees with them is refused rather than stored. A remote scorer
|
|
76
|
+
# that has drifted from its own explanation is exactly the thing this
|
|
77
|
+
# library must not launder into an audit record.
|
|
78
|
+
#
|
|
79
|
+
# Instances are frozen on construction and compare by value.
|
|
80
|
+
class MatchResult
|
|
81
|
+
extend T::Sig
|
|
82
|
+
|
|
83
|
+
# Canonical member order, matching the layout #to_h produces and the
|
|
84
|
+
# documented JSON shape.
|
|
85
|
+
#
|
|
86
|
+
# @api private
|
|
87
|
+
MEMBERS = T.let(
|
|
88
|
+
%i[score entity matched_name explanation query weights snapshot_id matcher_version backend verified
|
|
89
|
+
screened_at].freeze,
|
|
90
|
+
T::Array[Symbol]
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Where screening happened. `:local` is this gem doing the work against a
|
|
94
|
+
# list on this machine; #56 introduces the seam and the names of the
|
|
95
|
+
# others.
|
|
96
|
+
#
|
|
97
|
+
# @api private
|
|
98
|
+
DEFAULT_BACKEND = T.let(:local, Symbol)
|
|
99
|
+
|
|
100
|
+
# 0..100, one decimal place, and equal to the sum of the explanation.
|
|
101
|
+
sig { returns(Float) }
|
|
102
|
+
attr_reader :score
|
|
103
|
+
|
|
104
|
+
sig { returns(Entity) }
|
|
105
|
+
attr_reader :entity
|
|
106
|
+
|
|
107
|
+
# The specific spelling that produced the score, as its publisher wrote
|
|
108
|
+
# it. Usually an alias -- OFAC ships more of those than primary names --
|
|
109
|
+
# and a report that quoted the primary name instead would be describing a
|
|
110
|
+
# comparison that never happened.
|
|
111
|
+
sig { returns(Name) }
|
|
112
|
+
attr_reader :matched_name
|
|
113
|
+
|
|
114
|
+
# Never empty, and it adds up. See Scorer::Reason.
|
|
115
|
+
sig { returns(T::Array[Scorer::Reason]) }
|
|
116
|
+
attr_reader :explanation
|
|
117
|
+
|
|
118
|
+
# What was screened, and what the run was willing to return.
|
|
119
|
+
sig { returns(Query) }
|
|
120
|
+
attr_reader :query
|
|
121
|
+
|
|
122
|
+
# What each signal was worth when this was scored.
|
|
123
|
+
sig { returns(Scorer::Weights) }
|
|
124
|
+
attr_reader :weights
|
|
125
|
+
|
|
126
|
+
# The checksum of the list version this hit came off.
|
|
127
|
+
sig { returns(String) }
|
|
128
|
+
attr_reader :snapshot_id
|
|
129
|
+
|
|
130
|
+
sig { returns(String) }
|
|
131
|
+
attr_reader :matcher_version
|
|
132
|
+
|
|
133
|
+
sig { returns(Symbol) }
|
|
134
|
+
attr_reader :backend
|
|
135
|
+
|
|
136
|
+
# Whether the list this hit came off was cryptographically attested: it was
|
|
137
|
+
# loaded from a signed bundle (#57) that verified under a public key the
|
|
138
|
+
# installation supplied. False for a list this installation fetched and
|
|
139
|
+
# parsed itself, which is not a lesser answer -- it is a different claim.
|
|
140
|
+
#
|
|
141
|
+
# The sixth field of the reproducibility stamp, and the only one that is
|
|
142
|
+
# about where the data came from rather than about how it was scored. "We
|
|
143
|
+
# screened against OFAC" and "we screened against the OFAC bundle Treasury's
|
|
144
|
+
# mirror signed on 28 August" are different sentences in front of an
|
|
145
|
+
# examiner, and a result that could not tell them apart would leave the
|
|
146
|
+
# difference to somebody's memory.
|
|
147
|
+
sig { returns(T::Boolean) }
|
|
148
|
+
def verified? = @verified
|
|
149
|
+
|
|
150
|
+
# UTC, truncated to the second, which is the precision #to_h serializes.
|
|
151
|
+
sig { returns(Time) }
|
|
152
|
+
attr_reader :screened_at
|
|
153
|
+
|
|
154
|
+
class << self
|
|
155
|
+
extend T::Sig
|
|
156
|
+
|
|
157
|
+
# A scored candidate, stamped with the run that produced it. What
|
|
158
|
+
# Matcher builds every result with.
|
|
159
|
+
sig do
|
|
160
|
+
params(result: Scorer::Result, query: Query, snapshot_id: T.untyped, weights: Scorer::Weights,
|
|
161
|
+
screened_at: T.untyped, backend: T.untyped, matcher_version: T.untyped,
|
|
162
|
+
verified: T.untyped).returns(MatchResult)
|
|
163
|
+
end
|
|
164
|
+
def from_scorer(result, query:, snapshot_id:, weights:, screened_at: nil, backend: DEFAULT_BACKEND,
|
|
165
|
+
matcher_version: nil, verified: false)
|
|
166
|
+
new(entity: result.entity, matched_name: result.name, explanation: result.explanation,
|
|
167
|
+
score: result.score, query: query, weights: weights, snapshot_id: snapshot_id,
|
|
168
|
+
screened_at: screened_at, backend: backend, matcher_version: matcher_version, verified: verified)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Rebuilds a result from #to_h output, accepting string keys so a record
|
|
172
|
+
# survives the round-trip through JSON and back out of whatever a host
|
|
173
|
+
# stored it in.
|
|
174
|
+
sig { params(hash: T.untyped).returns(MatchResult) }
|
|
175
|
+
def from_h(hash)
|
|
176
|
+
attributes = hash.to_h.transform_keys(&:to_sym)
|
|
177
|
+
unknown = attributes.keys - MEMBERS
|
|
178
|
+
raise InvalidArgument, "unknown MatchResult attribute(s): #{unknown.join(", ")}" if unknown.any?
|
|
179
|
+
|
|
180
|
+
# `new(**hash)` past required keyword parameters is one of the few
|
|
181
|
+
# things Sorbet cannot check statically. #initialize validates what
|
|
182
|
+
# arrives -- including, here, the score against its explanation --
|
|
183
|
+
# which is where a bad round-trip is caught.
|
|
184
|
+
T.unsafe(self).new(**attributes, **objects(attributes))
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
private
|
|
188
|
+
|
|
189
|
+
# The members that are objects rather than scalars, rebuilt. Only the
|
|
190
|
+
# ones actually present: a record missing one has to reach #initialize
|
|
191
|
+
# missing it, so the error names the field rather than describing a nil.
|
|
192
|
+
sig { params(attributes: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
|
|
193
|
+
def objects(attributes)
|
|
194
|
+
{
|
|
195
|
+
entity: build(Entity, attributes[:entity]),
|
|
196
|
+
matched_name: build(Name, attributes[:matched_name]),
|
|
197
|
+
query: build(Query, attributes[:query]),
|
|
198
|
+
explanation: Array(attributes[:explanation]).map { |value| build(Scorer::Reason, value) }
|
|
199
|
+
}.select { |member, _| attributes.key?(member) }
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# A value that is already the object passes through, so from_h is safe
|
|
203
|
+
# to call on a half-deserialized hash.
|
|
204
|
+
sig { params(klass: T.untyped, value: T.untyped).returns(T.untyped) }
|
|
205
|
+
def build(klass, value) = value.is_a?(Hash) ? klass.from_h(value) : value
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# `score` is derived, not supplied. Passing it -- which is what .from_h
|
|
209
|
+
# does with a stored record -- asserts what the explanation should come
|
|
210
|
+
# to, and construction fails if it does not.
|
|
211
|
+
sig do
|
|
212
|
+
params(entity: T.untyped, matched_name: T.untyped, explanation: T.untyped, query: T.untyped,
|
|
213
|
+
weights: T.untyped, snapshot_id: T.untyped, score: T.untyped, matcher_version: T.untyped,
|
|
214
|
+
backend: T.untyped, verified: T.untyped, screened_at: T.untyped).void
|
|
215
|
+
end
|
|
216
|
+
def initialize(entity:, matched_name:, explanation:, query:, weights:, snapshot_id:, score: nil,
|
|
217
|
+
matcher_version: nil, backend: DEFAULT_BACKEND, verified: false, screened_at: nil)
|
|
218
|
+
@entity = T.let(instance!(:entity, Entity, entity), Entity)
|
|
219
|
+
@matched_name = T.let(instance!(:matched_name, Name, matched_name), Name)
|
|
220
|
+
@explanation = T.let(explanation!(explanation), T::Array[Scorer::Reason])
|
|
221
|
+
@query = T.let(query!(query), Query)
|
|
222
|
+
@weights = T.let(Scorer::Weights.build(weights), Scorer::Weights)
|
|
223
|
+
@snapshot_id = T.let(string!(:snapshot_id, snapshot_id), String)
|
|
224
|
+
@matcher_version = T.let(string!(:matcher_version, matcher_version || MATCHER_VERSION), String)
|
|
225
|
+
@backend = T.let(symbol!(:backend, backend || DEFAULT_BACKEND), Symbol)
|
|
226
|
+
@verified = T.let(verified == true, T::Boolean)
|
|
227
|
+
@screened_at = T.let(time!(screened_at), Time)
|
|
228
|
+
@score = T.let(score!(score), Float)
|
|
229
|
+
freeze
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# The list this hit came from, which every result has to name. Read off
|
|
233
|
+
# the entity rather than stored beside it: a source that could disagree
|
|
234
|
+
# with the record it describes is a field nobody can trust.
|
|
235
|
+
sig { returns(Symbol) }
|
|
236
|
+
def source = entity.source
|
|
237
|
+
|
|
238
|
+
# The threshold this run was willing to report at, which is half of what
|
|
239
|
+
# makes a hit -- or the absence of one -- mean anything.
|
|
240
|
+
sig { returns(Float) }
|
|
241
|
+
def threshold = query.threshold
|
|
242
|
+
|
|
243
|
+
# The reasons that lowered the score, which is the half of an explanation
|
|
244
|
+
# a reviewer clearing an alert reads first.
|
|
245
|
+
sig { returns(T::Array[Scorer::Reason]) }
|
|
246
|
+
def penalties = explanation.select(&:penalty?)
|
|
247
|
+
|
|
248
|
+
# The documented shape. Every value is a String, a Float, an Integer, an
|
|
249
|
+
# Array or a Hash of the same, so `JSON.generate(result.to_h)` needs
|
|
250
|
+
# nothing from this library and `MatchResult.from_h(JSON.parse(json))`
|
|
251
|
+
# rebuilds exactly this object.
|
|
252
|
+
sig { returns(T::Hash[Symbol, T.untyped]) }
|
|
253
|
+
def to_h
|
|
254
|
+
{
|
|
255
|
+
score: score,
|
|
256
|
+
entity: entity.to_h,
|
|
257
|
+
matched_name: matched_name.to_h,
|
|
258
|
+
explanation: explanation.map(&:to_h),
|
|
259
|
+
query: query.to_h,
|
|
260
|
+
weights: weights.to_h,
|
|
261
|
+
snapshot_id: snapshot_id,
|
|
262
|
+
matcher_version: matcher_version,
|
|
263
|
+
backend: backend,
|
|
264
|
+
verified: verified?,
|
|
265
|
+
screened_at: screened_at.iso8601
|
|
266
|
+
}
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
sig { params(other: T.untyped).returns(T::Boolean) }
|
|
270
|
+
def ==(other)
|
|
271
|
+
return false unless other.instance_of?(self.class)
|
|
272
|
+
|
|
273
|
+
to_h == other.to_h
|
|
274
|
+
end
|
|
275
|
+
alias eql? ==
|
|
276
|
+
|
|
277
|
+
sig { returns(Integer) }
|
|
278
|
+
def hash = [self.class, to_h].hash
|
|
279
|
+
|
|
280
|
+
sig { returns(String) }
|
|
281
|
+
def inspect = "#<#{self.class} #{score} #{matched_name.value.inspect} (#{entity.id}) #{snapshot_id}>"
|
|
282
|
+
|
|
283
|
+
private
|
|
284
|
+
|
|
285
|
+
sig { params(member: Symbol, klass: T.untyped, value: T.untyped).returns(T.untyped) }
|
|
286
|
+
def instance!(member, klass, value)
|
|
287
|
+
return value if value.is_a?(klass)
|
|
288
|
+
|
|
289
|
+
raise InvalidArgument, "#{member} must be an #{klass}, got #{value.class}"
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
sig { params(value: T.untyped).returns(Query) }
|
|
293
|
+
def query!(value)
|
|
294
|
+
return Query.build(value) unless value.nil?
|
|
295
|
+
|
|
296
|
+
raise InvalidArgument, "query is required -- a hit that cannot say what was screened is half a record"
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
sig { params(value: T.untyped).returns(T::Array[Scorer::Reason]) }
|
|
300
|
+
def explanation!(value)
|
|
301
|
+
reasons = Array(value)
|
|
302
|
+
raise InvalidArgument, "a result needs at least one reason -- the score is the explanation" if reasons.empty?
|
|
303
|
+
|
|
304
|
+
reasons.each { |reason| instance!(:explanation, Scorer::Reason, reason) }
|
|
305
|
+
reasons.dup.freeze
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# The score is the sum of the rounded contributions rather than the
|
|
309
|
+
# rounded sum -- the same arithmetic Scorer::Result does, so a result
|
|
310
|
+
# rebuilt from JSON comes to the number that was stored.
|
|
311
|
+
sig { params(supplied: T.untyped).returns(Float) }
|
|
312
|
+
def score!(supplied)
|
|
313
|
+
computed = explanation.sum(&:contribution).round(Scorer::Reason::PRECISION).to_f
|
|
314
|
+
return computed if supplied.nil? || Float(supplied).round(Scorer::Reason::PRECISION) == computed
|
|
315
|
+
|
|
316
|
+
raise InvalidArgument,
|
|
317
|
+
"score #{supplied} is not what this explanation comes to (#{computed}). The score is the sum of " \
|
|
318
|
+
"the reasons, so a result whose reasons no longer explain it cannot be built"
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
sig { params(member: Symbol, value: T.untyped).returns(String) }
|
|
322
|
+
def string!(member, value)
|
|
323
|
+
string = value.to_s.strip
|
|
324
|
+
raise InvalidArgument, "#{member} is required" if string.empty?
|
|
325
|
+
|
|
326
|
+
-string
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
sig { params(member: Symbol, value: T.untyped).returns(Symbol) }
|
|
330
|
+
def symbol!(member, value) = string!(member, value).to_sym
|
|
331
|
+
|
|
332
|
+
# Truncated to the second, the precision #to_h serializes, so a result
|
|
333
|
+
# read back is equal to the one that was written.
|
|
334
|
+
sig { params(value: T.untyped).returns(Time) }
|
|
335
|
+
def time!(value)
|
|
336
|
+
time = case value
|
|
337
|
+
when nil then Time.now
|
|
338
|
+
when Time then value
|
|
339
|
+
when String then Time.parse(value)
|
|
340
|
+
else raise InvalidArgument, "screened_at is not a time: #{value.inspect}"
|
|
341
|
+
end
|
|
342
|
+
Time.at(time.to_i).utc
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
end
|