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,159 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module ActiveSanction
7
+ module Similarity
8
+ # Token set ratio: compare what two names share against what each of them
9
+ # is, and take the best of it. 0..1.
10
+ #
11
+ # TokenSet.call("putin vladimir vladimirovich", "vladimir putin") # => 1.0
12
+ # TokenSet.call("vladimir putin", "vladimir zhirinovsky") # => 0.5714
13
+ # TokenSet.call("gazprom neft", "gazprombank") # => 0.5833
14
+ #
15
+ # ### The shape it exists for
16
+ #
17
+ # A token one side has and the other does not, which is the ordinary
18
+ # condition of a real query rather than an edge case. Lists carry the full
19
+ # legal name and a query carries what somebody typed: a patronymic that
20
+ # only Russian records bother with, a middle name a payment message
21
+ # dropped, a `bin` chain written out in full on one side and abbreviated on
22
+ # the other. Sorting does nothing for any of it -- TokenSort scores
23
+ # `PUTIN, Vladimir Vladimirovich` against `Vladimir Putin` at 0.5, because
24
+ # half the characters on the longer side have no partner and the edit
25
+ # distance charges for every one of them.
26
+ #
27
+ # ### The three strings
28
+ #
29
+ # Take both names as sets of tokens and split them three ways -- what they
30
+ # share, what only the left has, what only the right has -- then build:
31
+ #
32
+ # t0 the shared tokens, alphabetically
33
+ # t1 t0, then the left's own tokens, alphabetically
34
+ # t2 t0, then the right's own tokens, alphabetically
35
+ #
36
+ # and score the pair as the best of `t0/t1`, `t0/t2` and `t1/t2`. For
37
+ # `putin vladimir vladimirovich` against `vladimir putin` that is:
38
+ #
39
+ # t0 = "putin vladimir"
40
+ # t1 = "putin vladimir vladimirovich"
41
+ # t2 = "putin vladimir"
42
+ #
43
+ # `t0/t2` is a perfect match, and the answer is 1.0. Every one of the three
44
+ # comparisons runs on Levenshtein, for the reason TokenSort gives.
45
+ #
46
+ # The construction is Seatgeek's, from `fuzzywuzzy`'s `token_set_ratio`,
47
+ # and it is kept as published rather than adjusted. A screening score that
48
+ # nobody outside this repository can reproduce is a score that has to be
49
+ # argued from scratch every time an examiner asks about it.
50
+ #
51
+ # ### What that tolerance costs, and who pays it
52
+ #
53
+ # A name whose tokens are all present in the other scores 1.0, however
54
+ # much else the other one says:
55
+ #
56
+ # TokenSet.call("gazprom", "gazprom neft") # => 1.0
57
+ #
58
+ # Those are two different companies, and this ratio cannot tell them
59
+ # apart -- `t0/t2` is a string against itself whenever one side's tokens
60
+ # are a subset of the other's, and no length difference is large enough to
61
+ # change that. The same generosity finds `Vladimir Putin` inside `PUTIN,
62
+ # Vladimir Vladimirovich`, so it is not a bug to be fixed here; it is the
63
+ # single property this algorithm has, and it points in both directions.
64
+ #
65
+ # Recorded rather than lamented, and recorded in the specs as well: what
66
+ # keeps it from deciding a hit is that the scorer (#32) blends four
67
+ # numbers, and the other three all charge for the extra word. Levenshtein
68
+ # scores that pair 0.583 and TokenSort 0.583. A scorer that let this
69
+ # column vote alone would put every subsidiary of every listed parent in
70
+ # front of an analyst at 100.
71
+ #
72
+ # ### Sets, so a repeated token is one token
73
+ #
74
+ # `ALI, Ali Hassan` is `ali ali hassan` folded, and the second `ali` is not
75
+ # a second piece of evidence. Deduplicating is what the name of the
76
+ # algorithm says and what these lists want: a repeated given name is a
77
+ # naming convention, not a stronger signal, and counting it twice would
78
+ # make an entity's score depend on how many times its own name repeats.
79
+ module TokenSet
80
+ extend T::Sig
81
+
82
+ module_function
83
+
84
+ # The similarity of two already-folded names -- see Similarity for what
85
+ # `threshold:` does and what it promises, and for why either side may be
86
+ # a string or the tokens it splits into.
87
+ #
88
+ # The threshold is passed down to all three comparisons rather than
89
+ # applied to their maximum, which keeps the promise exactly: the largest
90
+ # of three numbers is at or above the cutoff precisely when one of them
91
+ # is, and that one comes back exact.
92
+ sig { params(left: Value, right: Value, threshold: Numeric).returns(Float).checked(:tests) }
93
+ def call(left, right, threshold: 0.0)
94
+ cutoff = Similarity.threshold!(threshold)
95
+ left_tokens = Similarity.tokens(left)
96
+ right_tokens = Similarity.tokens(right)
97
+ # A name with no tokens cannot be scored, and the three strings would
98
+ # all be empty and compare as identical if this fell through. Both
99
+ # empty is the answer the other algorithms give for two empty strings.
100
+ return 1.0 if left_tokens.empty? && right_tokens.empty?
101
+ return 0.0 if left_tokens.empty? || right_tokens.empty?
102
+
103
+ shared, left_full, right_full = strings(left_tokens, right_tokens)
104
+ best(shared, left_full, right_full, cutoff)
105
+ end
106
+
107
+ # The three strings this actually compares, in the order `t0, t1, t2`.
108
+ #
109
+ # Public for the reason TokenSort.sorted is: this is the whole of the
110
+ # difference between what a caller passed and what was scored, and a hit
111
+ # a compliance user cannot account for is a hit they cannot clear.
112
+ #
113
+ # Where the sets are made: `&` and `-` both deduplicate what they keep,
114
+ # apart from the repeats inside a name's own tokens, which `uniq`
115
+ # removes.
116
+ sig { params(left: T::Array[String], right: T::Array[String]).returns([String, String, String]).checked(:tests) }
117
+ def strings(left, right)
118
+ shared = (left & right).sort
119
+ [shared.join(" "),
120
+ (shared + (left - right).uniq.sort).join(" "),
121
+ (shared + (right - left).uniq.sort).join(" ")]
122
+ end
123
+
124
+ # 1.0 for any two non-empty names, which is not much of a bound and is
125
+ # the true one.
126
+ #
127
+ # The other three algorithms can rule a perfect score out from a length
128
+ # difference alone, because a character one name has and the other does
129
+ # not costs something wherever it falls. This one cannot: a subset scores
130
+ # 1.0 at any length, which is the whole of what it is for. A tighter
131
+ # number here would be a wrong one, and wrong in the direction that
132
+ # silently discards true matches -- a caller cannot tell a pair rejected
133
+ # by a ceiling from one that scored badly.
134
+ #
135
+ # So `threshold:` buys this ratio nothing before the comparison and only
136
+ # what the three Levenshtein calls can find inside it. That is a real
137
+ # cost, paid where the tolerance is, and it is the reason the scorer
138
+ # runs the cheap columns first.
139
+ sig { params(left_length: Integer, right_length: Integer).returns(Float).checked(:tests) }
140
+ def ceiling(left_length, right_length)
141
+ return left_length == right_length ? 1.0 : 0.0 if left_length.zero? || right_length.zero?
142
+
143
+ 1.0
144
+ end
145
+
146
+ # The best of the three, each computed against the caller's cutoff.
147
+ sig do
148
+ params(shared: String, left_full: String, right_full: String, cutoff: Float).returns(Float).checked(:tests)
149
+ end
150
+ def best(shared, left_full, right_full, cutoff)
151
+ [Levenshtein.call(shared, left_full, threshold: cutoff),
152
+ Levenshtein.call(shared, right_full, threshold: cutoff),
153
+ Levenshtein.call(left_full, right_full, threshold: cutoff)].max
154
+ end
155
+
156
+ private_class_method :best
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,120 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module ActiveSanction
7
+ module Similarity
8
+ # Token sort ratio: put both names' tokens in alphabetical order, then
9
+ # compare what comes out. 0..1.
10
+ #
11
+ # ActiveSanction::Similarity::TokenSort.call("abbas abu", "abu abbas") # => 1.0
12
+ # ActiveSanction::Similarity::TokenSort.call("smith john", "john smith") # => 1.0
13
+ # ActiveSanction::Similarity::TokenSort.call("abbas abu", "abbas abd") # => 0.8889
14
+ #
15
+ # ### The shape it exists for
16
+ #
17
+ # Sanctions lists store a personal name inverted and a query almost never
18
+ # is. OFAC publishes `ABBAS, Abu` and `ZAYDAN, Muhammad`; a customer
19
+ # record says `Abu Abbas` and a payment message says `Muhammad Zaydan`.
20
+ # The comma does not survive to be read here -- Normalizer turns
21
+ # punctuation into a space, so both sides arrive as the same tokens in
22
+ # opposite orders -- and character-level comparison is helpless in front of
23
+ # that:
24
+ #
25
+ # JaroWinkler.call("abbas abu", "abu abbas") # => 0.8053
26
+ # Levenshtein.call("abbas abu", "abu abbas") # => 0.3333
27
+ # TokenSort.call("abbas abu", "abu abbas") # => 1.0
28
+ #
29
+ # Those are two names that are not merely similar but identical, scored as
30
+ # a miss at the 85 this industry screens on. Sorting is what makes word
31
+ # order stop mattering, and word order is the difference between how these
32
+ # lists are written and how anybody types.
33
+ #
34
+ # It is worth being plain that this is not clever. Sorting is a blunt
35
+ # instrument that answers one question exactly -- are these the same words
36
+ # in some order -- and the reason it is the right instrument is that the
37
+ # inversion it defeats is a publishing convention rather than a
38
+ # coincidence, applied to essentially every individual on every list.
39
+ #
40
+ # ### What it costs, and why Levenshtein is still here
41
+ #
42
+ # Sorting destroys the information that two names were *already* in the
43
+ # same order, which for a pair that shares its leading tokens is
44
+ # information worth having:
45
+ #
46
+ # Levenshtein.call("kim jong un", "kim yong chol") # => 0.6154
47
+ # TokenSort.call("kim jong un", "kim yong chol") # => 0.4615
48
+ #
49
+ # `jong` and `yong` line up as written and are pulled apart by the sort,
50
+ # which puts `jong` next to `chol` and `un` next to `yong`. So this ratio
51
+ # is an additional question rather than a better one, and the scorer (#32)
52
+ # is where the two answers meet. A pipeline that sorted first and compared
53
+ # once would be strictly worse than one that does neither.
54
+ #
55
+ # ### Why Levenshtein underneath and not Jaro-Winkler
56
+ #
57
+ # Winkler's premise is that people get the beginning of a name right and
58
+ # drift later, which is true of a name as it is written and false of one
59
+ # whose words have just been put in alphabetical order: the front of a
60
+ # sorted string is whichever token happened to sort first, so the prefix
61
+ # bonus would be paying for a property of the alphabet. Sorted `abbas abd`
62
+ # and `abbas abu` share six characters of prefix for no reason anybody
63
+ # typed.
64
+ #
65
+ # Levenshtein also charges honestly for a token the other side does not
66
+ # have, which after sorting is most of what is left to measure. Being
67
+ # generous about that is the other ratio's job, and two generous
68
+ # algorithms stacked on each other is how a token ratio starts saying yes
69
+ # to everything.
70
+ #
71
+ # ### What it does not handle, which is why TokenSet exists
72
+ #
73
+ # A token on one side and not the other. Sorting lines the shared words up
74
+ # but does nothing about the ones with nowhere to go, and the edit
75
+ # distance charges for every character of them:
76
+ #
77
+ # TokenSort.call("putin vladimir vladimirovich", "vladimir putin") # => 0.5
78
+ #
79
+ # Half the name is a patronymic the query did not carry, and half is what
80
+ # this scores. That is the whole of TokenSet's job.
81
+ module TokenSort
82
+ extend T::Sig
83
+
84
+ module_function
85
+
86
+ # The similarity of two already-folded names -- see Similarity for what
87
+ # `threshold:` does and what it promises, and for why either side may be
88
+ # a string or the tokens it splits into.
89
+ sig { params(left: Value, right: Value, threshold: Numeric).returns(Float).checked(:tests) }
90
+ def call(left, right, threshold: 0.0)
91
+ cutoff = Similarity.threshold!(threshold)
92
+
93
+ Levenshtein.call(sorted(left), sorted(right), threshold: cutoff)
94
+ end
95
+
96
+ # The string this actually compares: the name's tokens in alphabetical
97
+ # order, single-spaced.
98
+ #
99
+ # Public because a score nobody can account for is a score nobody can
100
+ # defend, and `sorted` is the whole of the difference between what a
101
+ # caller passed and what was compared. An analyst asking why `kim jong
102
+ # un` scored what it did against `kim yong chol` gets the answer by
103
+ # printing this.
104
+ sig { params(value: Value).returns(String).checked(:tests) }
105
+ def sorted(value) = Similarity.tokens(value).sort.join(" ")
106
+
107
+ # Levenshtein's ceiling, on the lengths of the two folded names, because
108
+ # sorting a folded name's tokens does not change its length -- the same
109
+ # characters and the same single spaces come out in a different order.
110
+ # That equality is the one thing this delegation rests on, and it is the
111
+ # reason the contract says *folded* names: a string carrying double
112
+ # spaces or a leading one is shorter after the sort than the length it
113
+ # reports, and a ceiling computed from the longer length would come in
114
+ # under the real score, which is the one direction a bound may never
115
+ # err in.
116
+ sig { params(left_length: Integer, right_length: Integer).returns(Float).checked(:tests) }
117
+ def ceiling(left_length, right_length) = Levenshtein.ceiling(left_length, right_length)
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,183 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module ActiveSanction
7
+ # How close two folded names are, as a number between 0 and 1. Stage 3 of
8
+ # the matching pipeline.
9
+ #
10
+ # left = ActiveSanction::Normalizer.call("ABBAS, Abu")
11
+ # right = ActiveSanction::Normalizer.call("Abu Abbas")
12
+ #
13
+ # ActiveSanction::Similarity::JaroWinkler.call(left.value, right.value) # => 0.8053
14
+ # ActiveSanction::Similarity::Levenshtein.call(left.value, right.value) # => 0.3333
15
+ # ActiveSanction::Similarity::TokenSort.call(left.tokens, right.tokens) # => 1.0
16
+ # ActiveSanction::Similarity::TokenSet.call(left.tokens, right.tokens) # => 1.0
17
+ #
18
+ # Four algorithms and nothing else. Two compare characters -- JaroWinkler
19
+ # and Levenshtein, stage 3a -- and two compare tokens -- TokenSort and
20
+ # TokenSet, stage 3b. The token ratios are rearrangements of the same two
21
+ # names with Levenshtein run over the result, so there is still exactly one
22
+ # place in this library that knows how to walk two strings. The scorer (#32)
23
+ # blends all four, and nothing in the library has any other way to ask how
24
+ # close two names are.
25
+ #
26
+ # ### Why four
27
+ #
28
+ # Each of them is generous about something, and their disagreements are the
29
+ # point:
30
+ #
31
+ # JW Lev Sort Set
32
+ # abbas abu / abu abbas 0.805 0.333 1.000 1.000
33
+ # putin vladimir vladimirovich /
34
+ # vladimir putin 0.679 0.357 0.500 1.000
35
+ # gazprom / gazprom neft 0.917 0.583 0.583 1.000
36
+ # kim jong un / kim yong chol 0.869 0.615 0.462 0.462
37
+ #
38
+ # Row one is an inverted name, which is the single most common query shape
39
+ # against these lists, which character comparison misses and sorting makes
40
+ # trivial. Row two adds a patronymic on one side, which sorting cannot
41
+ # absorb either and the set ratio is built for. Row three is two different
42
+ # companies, and the 1.000 is what that tolerance costs. Row four is a pair
43
+ # already written in the same order, where sorting *loses* the information
44
+ # that they are, and the character algorithms are the honest ones.
45
+ #
46
+ # No single column is the score. #32 blends them, and rows three and four
47
+ # are why it has to.
48
+ #
49
+ # Stage 3c is next door, in Phonetics, and not in here: Double Metaphone
50
+ # answers what a name *sounds* like with a key rather than a number, and a
51
+ # key cannot be blended with four scores or held to the contract below.
52
+ #
53
+ # ### Why pure Ruby
54
+ #
55
+ # `fuzzy_string_match` and the other C extensions are faster per call, and
56
+ # they would put a native build in the path of every application that
57
+ # installs this gem and in every container image the future service is
58
+ # deployed from. That is a poor trade at the volume involved: the corpus is
59
+ # roughly 46,000 name strings, and the inverted index (#31) narrows a query
60
+ # to a few hundred candidates before any of this runs.
61
+ #
62
+ # `rake benchmark:similarity` is what that costs, and the numbers are the
63
+ # argument. On name-length strings without a JIT, Jaro-Winkler runs about
64
+ # 20 us per pair and Levenshtein about 60. The token ratios cost what they
65
+ # are: the sort ratio is one Levenshtein call on a rearranged string and
66
+ # prices like one, and the set ratio is three and costs about 110. The whole
67
+ # of a 500-candidate query, all four on every name, is about 25 ms with a
68
+ # threshold passed and six times that without one; YJIT takes a factor of
69
+ # two and a half off both and puts a thresholded query near 10 ms. That is
70
+ # the budget a screening call has, and it is the reason a threshold is worth
71
+ # passing -- the exits below are most of the difference between those two
72
+ # numbers.
73
+ #
74
+ # ### The contract all four keep
75
+ #
76
+ # **Folded names in.** Nothing here normalizes anything -- see Normalizer
77
+ # for why the fold happens once, at one entry point, for both sides of a
78
+ # comparison. A caller passes `form.value`, or `form.tokens` to either of
79
+ # the token ratios; passing raw publisher text instead scores the case and
80
+ # the punctuation rather than the name.
81
+ #
82
+ # **A similarity out, not a distance.** 1.0 is identical, 0.0 is nothing in
83
+ # common, and the number is a Float that is never rounded here. The scorer
84
+ # works in 0..100 and does its own rounding; rounding twice is how a
85
+ # threshold comparison starts disagreeing with the number printed beside it.
86
+ #
87
+ # **`threshold:` is an optimization, not a filter.** Passing one lets the
88
+ # algorithm stop as soon as the score provably cannot reach it, and any
89
+ # score below it is reported as 0.0 rather than computed exactly:
90
+ #
91
+ # Similarity::Levenshtein.call("gazprom", "gazprom neft") # => 0.5833...
92
+ # Similarity::Levenshtein.call("gazprom", "gazprom neft", threshold: 0.8)
93
+ # # => 0.0
94
+ #
95
+ # A score at or above the threshold is exactly the score the same call
96
+ # without a threshold returns -- the early exits are bounds on what a pair
97
+ # can reach, never approximations of what it did reach. `ceiling` on each
98
+ # algorithm is that bound, exposed so it can be held to that promise. How
99
+ # much it is worth differs: Levenshtein's is tight, Jaro-Winkler's is loose
100
+ # because the prefix bonus can add 0.4 to anything, and TokenSet has none at
101
+ # all, because a name that is a subset of another scores 1.0 at any length.
102
+ #
103
+ # @api private
104
+ module Similarity
105
+ extend T::Sig
106
+ extend T::Helpers
107
+
108
+ # Called as `Similarity.threshold!`, which is where `raise` comes from.
109
+ requires_ancestor { Kernel }
110
+
111
+ # A folded name, as either the string or the tokens it splits into. The
112
+ # token ratios take either on either side, because their two callers hold
113
+ # different things: a Form already carries `tokens` and should not pay for
114
+ # a split per comparison, while a spec, a console and the benchmark are
115
+ # written in strings.
116
+ Value = T.type_alias { T.any(String, T::Array[String]) }
117
+
118
+ module_function
119
+
120
+ # A threshold as a Float, or an InvalidArgument.
121
+ #
122
+ # The 0..1 range is checked rather than assumed because the surrounding
123
+ # library speaks in 0..100 -- the scorer's weights, its thresholds and
124
+ # everything a compliance user reads are percentages -- and a `85` that
125
+ # arrives here unchecked does not fail. It silently rejects every pair,
126
+ # which reads as "nothing matched" and is the one failure this domain
127
+ # cannot afford.
128
+ sig { params(threshold: Numeric).returns(Float).checked(:tests) }
129
+ def threshold!(threshold)
130
+ cutoff = threshold.to_f
131
+ return cutoff if cutoff.between?(0.0, 1.0)
132
+
133
+ raise InvalidArgument,
134
+ "threshold must be between 0.0 and 1.0, got #{threshold.inspect} -- " \
135
+ "these are similarities on a 0..1 scale, not percentages"
136
+ end
137
+
138
+ # The form both algorithms work in: an Array of codepoints.
139
+ #
140
+ # Codepoints rather than characters because comparing Integers is cheaper
141
+ # than comparing one-character Strings, and both algorithms compare in
142
+ # their innermost loop. Codepoints rather than bytes because a byte is not
143
+ # a character outside ASCII, and half of these names are not ASCII: `ж` is
144
+ # two bytes, and a byte-wise edit distance would charge two edits for
145
+ # changing one letter and score Cyrillic pairs against a different scale
146
+ # than Latin ones.
147
+ #
148
+ # A string that is not valid UTF-8 is scrubbed rather than raising, for
149
+ # the reason Form repairs one: a single stray byte in a government file
150
+ # must not take a whole index build down with it. Normalizer has already
151
+ # done this to anything that came through it.
152
+ sig { params(string: String).returns(T::Array[Integer]).checked(:tests) }
153
+ def codepoints(string) = string.valid_encoding? ? string.codepoints : string.scrub.codepoints
154
+
155
+ # The form both token ratios work in: an Array of tokens.
156
+ #
157
+ # `String#split` with no argument, which is all the splitting a folded
158
+ # value needs: Normalizer's stage 4 turned every punctuation mark into a
159
+ # space and stage 5 collapsed the runs, so whitespace is the only boundary
160
+ # left in the string and there are no empty tokens at either end.
161
+ #
162
+ # An Array is taken as it stands. That is the path that matters -- the
163
+ # index (#31) hands the scorer a few hundred candidates, each carrying
164
+ # several names, and every one of those names has already been split once
165
+ # by the Form it lives in.
166
+ #
167
+ # Scrubbed first when it has to be, for the reason `codepoints` scrubs and
168
+ # Form repairs: `String#split` raises on a byte sequence that is not valid
169
+ # UTF-8, and one stray byte in a government file must not take an index
170
+ # build down with it.
171
+ sig { params(value: Value).returns(T::Array[String]).checked(:tests) }
172
+ def tokens(value)
173
+ return value unless value.is_a?(String)
174
+
175
+ (value.valid_encoding? ? value : value.scrub).split
176
+ end
177
+ end
178
+ end
179
+
180
+ require "active_sanction/similarity/jaro_winkler"
181
+ require "active_sanction/similarity/levenshtein"
182
+ require "active_sanction/similarity/token_sort"
183
+ require "active_sanction/similarity/token_set"