Class: ActiveSanction::MatchResult

Inherits:
Object
  • Object
show all
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 retunes dob_conflict changes 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

Class Method Summary collapse

Instance Method Summary collapse

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.

Parameters:

  • entity (T.untyped)
  • matched_name (T.untyped)
  • explanation (T.untyped)
  • query (T.untyped)
  • weights (T.untyped)
  • snapshot_id (T.untyped)
  • score (T.untyped) (defaults to: nil)
  • matcher_version (T.untyped) (defaults to: nil)
  • backend (T.untyped) (defaults to: DEFAULT_BACKEND)
  • verified (T.untyped) (defaults to: false)
  • screened_at (T.untyped) (defaults to: nil)


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)

Returns:

  • (Symbol)


134
135
136
# File 'lib/active_sanction/match_result.rb', line 134

def backend
  @backend
end

#entity ⇒ Entity (readonly)

Returns:



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.

Returns:



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.

Returns:



112
113
114
# File 'lib/active_sanction/match_result.rb', line 112

def matched_name
  @matched_name
end

#matcher_version ⇒ String (readonly)

Returns:

  • (String)


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.

Returns:



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.

Returns:

  • (Float)


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.

Returns:

  • (Time)


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.

Returns:

  • (String)


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.

Returns:



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.

Parameters:

  • hash (T.untyped)

Returns:

Raises:



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.

Parameters:

  • result (Scorer::Result)
  • query (Query)
  • snapshot_id (T.untyped)
  • weights (Scorer::Weights)
  • screened_at (T.untyped) (defaults to: nil)
  • backend (T.untyped) (defaults to: DEFAULT_BACKEND)
  • matcher_version (T.untyped) (defaults to: nil)
  • verified (T.untyped) (defaults to: false)

Returns:



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?

Parameters:

  • other (T.untyped)

Returns:

  • (Boolean)


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

Returns:

  • (Integer)


278
# File 'lib/active_sanction/match_result.rb', line 278

def hash = [self.class, to_h].hash

#inspect ⇒ String

Returns:

  • (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.

Returns:



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.

Returns:

  • (Symbol)


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.

Returns:

  • (Float)


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.

Returns:

  • (Hash{Symbol => T.untyped})


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.

Returns:

  • (Boolean)


148
# File 'lib/active_sanction/match_result.rb', line 148

def verified? = @verified