Class: ActiveSanction::MatchResult
- Inherits:
-
Object
- Object
- ActiveSanction::MatchResult
- Extended by:
- T::Sig
- Defined in:
- lib/active_sanction/match_result.rb
Overview
One hit, and everything needed to defend it years later.
result = ActiveSanction.screen(name: "Bosco Ntaganda", type: :individual).first
result.score # => 100.0
result.entity.id # => "un_consolidated:6908021"
result.matched_name # => the specific Name that produced the score
result.source # => :un_consolidated
result.explanation # => [Reason, ...], summing to the score
result.snapshot_id # => "sha256:9f86d081884c7d65..."
result.matcher_version # => "1"
result.verified? # => false, unless the list came from a signed bundle
result.screened_at # => 2026-09-06 11:04:02 UTC
This is the most permanent object in the library
Everything else here is a step in a pipeline. This is what leaves the
library and goes into a customer's audit record, and it is read by people
who do not have this process, this configuration, or this version of the
gem -- an examiner asking in 2029 why a payment was cleared in 2026. So it
serializes to a documented shape, from_h rebuilds it losslessly from
that shape, and every field that could have changed the answer is on it.
The reproducibility stamp
Four fields make a past decision re-derivable, and each of them is a way the same query could score differently today:
snapshot_id-- the checksum (#8) of the exact list version that answered, for the source this hit came from. Publishers overwrite their files in place, so "the OFAC list" is not a thing that can be cited; a checksum is.matcher_version-- see Matcher::VERSION. Which pipeline scored it.weights-- what each signal was worth. A host that retunesdob_conflictchanges what every past decision would score today, and a record that did not say which numbers it was made under could not be told apart from one that would score the same.query-- what was screened, and under what threshold and limit. A record that says what was found but not what was asked is half an answer: a hit at 78 means one thing under a threshold of 75 and cannot have existed under 85.
backend is the fifth, and it is here for #56: a hosted backend answers
the same call against data somebody else keeps fresh, and an audit record
has to say which one answered.
verified is the sixth, and it is the only one about provenance rather
than about scoring: whether the list this hit came off was a signed bundle
(#57) that checked out under a key this installation holds. See #verified?.
Constructible without the local scorer, on purpose
.from_scorer is the convenience the Local backend uses. .new takes
every field outright, and that is the seam: a hosted backend deserializes
what a service returned and builds one of these directly. If this were
constructible only from a Scorer::Result, the two products would already
have two result types.
What both routes are held to is the invariant the explanation carries --
score is the sum of the reasons, rounded once, and a score: passed in
that disagrees with them is refused rather than stored. A remote scorer
that has drifted from its own explanation is exactly the thing this
library must not launder into an audit record.
Instances are frozen on construction and compare by value.
Instance Attribute Summary collapse
- #backend ⇒ Symbol readonly
- #entity ⇒ Entity readonly
-
#explanation ⇒ Array<Scorer::Reason>
readonly
Never empty, and it adds up.
-
#matched_name ⇒ Name
readonly
The specific spelling that produced the score, as its publisher wrote it.
- #matcher_version ⇒ String readonly
-
#query ⇒ Query
readonly
What was screened, and what the run was willing to return.
-
#score ⇒ Float
readonly
0..100, one decimal place, and equal to the sum of the explanation.
-
#screened_at ⇒ Time
readonly
UTC, truncated to the second, which is the precision #to_h serializes.
-
#snapshot_id ⇒ String
readonly
The checksum of the list version this hit came off.
-
#weights ⇒ Scorer::Weights
readonly
What each signal was worth when this was scored.
Class Method Summary collapse
-
.from_h(hash) ⇒ MatchResult
Rebuilds a result from #to_h output, accepting string keys so a record survives the round-trip through JSON and back out of whatever a host stored it in.
-
.from_scorer(result, query:, snapshot_id:, weights:, screened_at: nil, backend: DEFAULT_BACKEND, matcher_version: nil, verified: false) ⇒ MatchResult
A scored candidate, stamped with the run that produced it.
Instance Method Summary collapse
- #==(other) ⇒ Boolean (also: #eql?)
- #hash ⇒ Integer
-
#initialize(entity:, matched_name:, explanation:, query:, weights:, snapshot_id:, score: nil, matcher_version: nil, backend: DEFAULT_BACKEND, verified: false, screened_at: nil) ⇒ void
constructor
scoreis derived, not supplied. - #inspect ⇒ String
-
#penalties ⇒ Array<Scorer::Reason>
The reasons that lowered the score, which is the half of an explanation a reviewer clearing an alert reads first.
-
#source ⇒ Symbol
The list this hit came from, which every result has to name.
-
#threshold ⇒ Float
The threshold this run was willing to report at, which is half of what makes a hit -- or the absence of one -- mean anything.
-
#to_h ⇒ Hash{Symbol => T.untyped}
The documented shape.
-
#verified? ⇒ Boolean
Whether the list this hit came off was cryptographically attested: it was loaded from a signed bundle (#57) that verified under a public key the installation supplied.
Constructor Details
#initialize(entity:, matched_name:, explanation:, query:, weights:, snapshot_id:, score: nil, matcher_version: nil, backend: DEFAULT_BACKEND, verified: false, screened_at: nil) ⇒ void
score is derived, not supplied. Passing it -- which is what .from_h
does with a stored record -- asserts what the explanation should come
to, and construction fails if it does not.
216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 |
# File 'lib/active_sanction/match_result.rb', line 216 def initialize(entity:, matched_name:, explanation:, query:, weights:, snapshot_id:, score: nil, matcher_version: nil, backend: DEFAULT_BACKEND, verified: false, screened_at: nil) @entity = T.let(instance!(:entity, Entity, entity), Entity) @matched_name = T.let(instance!(:matched_name, Name, matched_name), Name) @explanation = T.let(explanation!(explanation), T::Array[Scorer::Reason]) @query = T.let(query!(query), Query) @weights = T.let(Scorer::Weights.build(weights), Scorer::Weights) @snapshot_id = T.let(string!(:snapshot_id, snapshot_id), String) @matcher_version = T.let(string!(:matcher_version, matcher_version || MATCHER_VERSION), String) @backend = T.let(symbol!(:backend, backend || DEFAULT_BACKEND), Symbol) @verified = T.let(verified == true, T::Boolean) @screened_at = T.let(time!(screened_at), Time) @score = T.let(score!(score), Float) freeze end |
Instance Attribute Details
#backend ⇒ Symbol (readonly)
134 135 136 |
# File 'lib/active_sanction/match_result.rb', line 134 def backend @backend end |
#entity ⇒ Entity (readonly)
105 106 107 |
# File 'lib/active_sanction/match_result.rb', line 105 def entity @entity end |
#explanation ⇒ Array<Scorer::Reason> (readonly)
Never empty, and it adds up. See Scorer::Reason.
116 117 118 |
# File 'lib/active_sanction/match_result.rb', line 116 def explanation @explanation end |
#matched_name ⇒ Name (readonly)
The specific spelling that produced the score, as its publisher wrote it. Usually an alias -- OFAC ships more of those than primary names -- and a report that quoted the primary name instead would be describing a comparison that never happened.
112 113 114 |
# File 'lib/active_sanction/match_result.rb', line 112 def matched_name @matched_name end |
#matcher_version ⇒ String (readonly)
131 132 133 |
# File 'lib/active_sanction/match_result.rb', line 131 def matcher_version @matcher_version end |
#query ⇒ Query (readonly)
What was screened, and what the run was willing to return.
120 121 122 |
# File 'lib/active_sanction/match_result.rb', line 120 def query @query end |
#score ⇒ Float (readonly)
0..100, one decimal place, and equal to the sum of the explanation.
102 103 104 |
# File 'lib/active_sanction/match_result.rb', line 102 def score @score end |
#screened_at ⇒ Time (readonly)
UTC, truncated to the second, which is the precision #to_h serializes.
152 153 154 |
# File 'lib/active_sanction/match_result.rb', line 152 def screened_at @screened_at end |
#snapshot_id ⇒ String (readonly)
The checksum of the list version this hit came off.
128 129 130 |
# File 'lib/active_sanction/match_result.rb', line 128 def snapshot_id @snapshot_id end |
#weights ⇒ Scorer::Weights (readonly)
What each signal was worth when this was scored.
124 125 126 |
# File 'lib/active_sanction/match_result.rb', line 124 def weights @weights end |
Class Method Details
.from_h(hash) ⇒ MatchResult
Rebuilds a result from #to_h output, accepting string keys so a record survives the round-trip through JSON and back out of whatever a host stored it in.
175 176 177 178 179 180 181 182 183 184 185 |
# File 'lib/active_sanction/match_result.rb', line 175 def from_h(hash) attributes = hash.to_h.transform_keys(&:to_sym) unknown = attributes.keys - MEMBERS raise InvalidArgument, "unknown MatchResult attribute(s): #{unknown.join(", ")}" if unknown.any? # `new(**hash)` past required keyword parameters is one of the few # things Sorbet cannot check statically. #initialize validates what # arrives -- including, here, the score against its explanation -- # which is where a bad round-trip is caught. T.unsafe(self).new(**attributes, **objects(attributes)) end |
.from_scorer(result, query:, snapshot_id:, weights:, screened_at: nil, backend: DEFAULT_BACKEND, matcher_version: nil, verified: false) ⇒ MatchResult
A scored candidate, stamped with the run that produced it. What Matcher builds every result with.
164 165 166 167 168 169 |
# File 'lib/active_sanction/match_result.rb', line 164 def from_scorer(result, query:, snapshot_id:, weights:, screened_at: nil, backend: DEFAULT_BACKEND, matcher_version: nil, verified: false) new(entity: result.entity, matched_name: result.name, explanation: result.explanation, score: result.score, query: query, weights: weights, snapshot_id: snapshot_id, screened_at: screened_at, backend: backend, matcher_version: matcher_version, verified: verified) end |
Instance Method Details
#==(other) ⇒ Boolean Also known as: eql?
270 271 272 273 274 |
# File 'lib/active_sanction/match_result.rb', line 270 def ==(other) return false unless other.instance_of?(self.class) to_h == other.to_h end |
#hash ⇒ Integer
278 |
# File 'lib/active_sanction/match_result.rb', line 278 def hash = [self.class, to_h].hash |
#inspect ⇒ String
281 |
# File 'lib/active_sanction/match_result.rb', line 281 def inspect = "#<#{self.class} #{score} #{matched_name.value.inspect} (#{entity.id}) #{snapshot_id}>" |
#penalties ⇒ Array<Scorer::Reason>
The reasons that lowered the score, which is the half of an explanation a reviewer clearing an alert reads first.
246 |
# File 'lib/active_sanction/match_result.rb', line 246 def penalties = explanation.select(&:penalty?) |
#source ⇒ Symbol
The list this hit came from, which every result has to name. Read off the entity rather than stored beside it: a source that could disagree with the record it describes is a field nobody can trust.
236 |
# File 'lib/active_sanction/match_result.rb', line 236 def source = entity.source |
#threshold ⇒ Float
The threshold this run was willing to report at, which is half of what makes a hit -- or the absence of one -- mean anything.
241 |
# File 'lib/active_sanction/match_result.rb', line 241 def threshold = query.threshold |
#to_h ⇒ Hash{Symbol => T.untyped}
The documented shape. Every value is a String, a Float, an Integer, an
Array or a Hash of the same, so JSON.generate(result.to_h) needs
nothing from this library and MatchResult.from_h(JSON.parse(json))
rebuilds exactly this object.
253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 |
# File 'lib/active_sanction/match_result.rb', line 253 def to_h { score: score, entity: entity.to_h, matched_name: matched_name.to_h, explanation: explanation.map(&:to_h), query: query.to_h, weights: weights.to_h, snapshot_id: snapshot_id, matcher_version: matcher_version, backend: backend, verified: verified?, screened_at: screened_at.iso8601 } end |
#verified? ⇒ Boolean
Whether the list this hit came off was cryptographically attested: it was loaded from a signed bundle (#57) that verified under a public key the installation supplied. False for a list this installation fetched and parsed itself, which is not a lesser answer -- it is a different claim.
The sixth field of the reproducibility stamp, and the only one that is about where the data came from rather than about how it was scored. "We screened against OFAC" and "we screened against the OFAC bundle Treasury's mirror signed on 28 August" are different sentences in front of an examiner, and a result that could not tell them apart would leave the difference to somebody's memory.
148 |
# File 'lib/active_sanction/match_result.rb', line 148 def verified? = @verified |