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,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