Class: ActiveSanction::Doctor::Checkup

Inherits:
Object
  • Object
show all
Extended by:
T::Sig
Defined in:
lib/active_sanction/doctor/checkup.rb

Overview

One source's fresh profile held against what it was last time, and against the floors its adapter committed to. Produces findings and nothing else -- it never fetches, never stores, and never decides what to do about what it found.

Checkup.new(source: :ofac_sdn, observed: today, baseline: yesterday).findings

The baseline is the last stored snapshot, not a committed number

A threshold committed per adapter ("expect ~19,321 rows +/- 2,000") goes stale on its own, and the day somebody bumps it to make the build pass is the day it stops being read. The previous snapshot does not go stale, costs nothing to maintain, and catches what a fixed threshold cannot: a fill rate that has drifted from 61% to 12% is invisible to any bound wide enough to have survived three years of a list growing.

Floors are the coarse backstop underneath it, for the run that has nothing to compare against -- a first sync, a new source, a store that was cleared. Without them a first run would have to either pass silently or fail loudly, and both are wrong.

What separates a warn from an error

Not the size of the number. Whether the reading can be explained by the list changing rather than by the file changing.

A third of the records disappearing is a warn: a delisting wave looks exactly like a truncated download, and deciding automatically that it was the first is how a compliance tool ends up quietly screening against a list it has thrown half of away. A column that used to hold numbers and now holds company names is an error, and so is every record on a list losing a field all of them used to carry, because nothing a publisher does to its list produces either -- only something done to its file does.

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(source:, observed:, baseline: nil, floors: {}, tolerance: Configuration::DEFAULT_DOCTOR_TOLERANCE) ⇒ void

Parameters:

  • source (T.untyped)
  • observed (Profile)
  • baseline (Profile, nil) (defaults to: nil)
  • floors (T.untyped) (defaults to: {})
  • tolerance (T.untyped) (defaults to: Configuration::DEFAULT_DOCTOR_TOLERANCE)


96
97
98
99
100
101
102
# File 'lib/active_sanction/doctor/checkup.rb', line 96

def initialize(source:, observed:, baseline: nil, floors: {}, tolerance: Configuration::DEFAULT_DOCTOR_TOLERANCE)
  @source = T.let(source.to_sym, Symbol)
  @observed = T.let(observed, Profile)
  @baseline = T.let(baseline, T.nilable(Profile))
  @floors = T.let(floors.to_h { |name, value| [name.to_sym, value] }.freeze, T::Hash[Symbol, Numeric])
  @tolerance = T.let(Float(tolerance), Float)
end

Instance Attribute Details

#baseline ⇒ Profile? (readonly)

What this source measured last time, or nil for a run with nothing to compare against.

Returns:



80
81
82
# File 'lib/active_sanction/doctor/checkup.rb', line 80

def baseline
  @baseline
end

#floors ⇒ Hash{Symbol => Numeric} (readonly)

Check name to the value the adapter committed to as a lower bound. See Sources::Definition#floor.

Returns:

  • (Hash{Symbol => Numeric})


85
86
87
# File 'lib/active_sanction/doctor/checkup.rb', line 85

def floors
  @floors
end

#observed ⇒ Profile (readonly)

Returns:



75
76
77
# File 'lib/active_sanction/doctor/checkup.rb', line 75

def observed
  @observed
end

#source ⇒ Symbol (readonly)

Returns:

  • (Symbol)


72
73
74
# File 'lib/active_sanction/doctor/checkup.rb', line 72

def source
  @source
end

#tolerance ⇒ Float (readonly)

How far a measurement may move before it is worth a finding, as a share of what it was.

Returns:

  • (Float)


90
91
92
# File 'lib/active_sanction/doctor/checkup.rb', line 90

def tolerance
  @tolerance
end

Instance Method Details

#findings ⇒ Array<Finding>

Every finding, most serious first and stable within a severity so that two runs of an unchanged list produce identical output.

Returns:



107
108
109
110
# File 'lib/active_sanction/doctor/checkup.rb', line 107

def findings
  collected = empty_finding || (columns + record_count + fill + remarks_coverage + warnings + orphans)
  collected.sort_by { |finding| [-Finding::SEVERITIES.index(finding.severity).to_i, finding.check.to_s] }
end