sec_id 6.1.0 → 7.1.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/MIGRATION.md +147 -0
- data/README.md +108 -45
- data/lib/sec_id/base.rb +36 -17
- data/lib/sec_id/cei.rb +8 -7
- data/lib/sec_id/cik.rb +1 -1
- data/lib/sec_id/concerns/checkable.rb +90 -46
- data/lib/sec_id/concerns/generatable.rb +5 -5
- data/lib/sec_id/concerns/normalizable.rb +4 -4
- data/lib/sec_id/concerns/suggestable.rb +168 -0
- data/lib/sec_id/concerns/validatable.rb +7 -7
- data/lib/sec_id/cusip.rb +9 -8
- data/lib/sec_id/deprecation.rb +26 -0
- data/lib/sec_id/dti.rb +9 -14
- data/lib/sec_id/figi.rb +10 -9
- data/lib/sec_id/iban.rb +21 -20
- data/lib/sec_id/isin.rb +10 -9
- data/lib/sec_id/lei.rb +11 -10
- data/lib/sec_id/occ.rb +1 -1
- data/lib/sec_id/sedol.rb +12 -11
- data/lib/sec_id/suggestion.rb +59 -0
- data/lib/sec_id/upi.rb +77 -0
- data/lib/sec_id/valoren.rb +2 -2
- data/lib/sec_id/version.rb +1 -1
- data/lib/sec_id/wkn.rb +1 -1
- data/lib/sec_id.rb +43 -3
- data/sec_id.gemspec +5 -4
- data/sig/sec_id/base.rbs +16 -5
- data/sig/sec_id/cei.rbs +4 -0
- data/sig/sec_id/concerns/checkable.rbs +18 -7
- data/sig/sec_id/concerns/generatable.rbs +1 -1
- data/sig/sec_id/concerns/suggestable.rbs +32 -0
- data/sig/sec_id/concerns/validatable.rbs +1 -1
- data/sig/sec_id/cusip.rbs +4 -0
- data/sig/sec_id/deprecation.rbs +6 -0
- data/sig/sec_id/dti.rbs +6 -1
- data/sig/sec_id/figi.rbs +4 -0
- data/sig/sec_id/iban.rbs +7 -3
- data/sig/sec_id/isin.rbs +4 -0
- data/sig/sec_id/lei.rbs +5 -1
- data/sig/sec_id/sedol.rbs +4 -0
- data/sig/sec_id/suggestion.rbs +23 -0
- data/sig/sec_id/upi.rbs +29 -0
- data/sig/sec_id.rbs +7 -2
- metadata +14 -5
data/lib/sec_id/base.rb
CHANGED
|
@@ -8,28 +8,28 @@ module SecID
|
|
|
8
8
|
# - ID_REGEX constant with named capture groups for parsing
|
|
9
9
|
# - initialize method that calls parse and extracts components
|
|
10
10
|
#
|
|
11
|
-
# Subclasses with
|
|
12
|
-
# which provides
|
|
11
|
+
# Subclasses with checksum should also include the Checkable concern,
|
|
12
|
+
# which provides checksum validation, calculation, and restoration.
|
|
13
13
|
#
|
|
14
|
-
# @example Implementing a
|
|
14
|
+
# @example Implementing a checksum identifier
|
|
15
15
|
# class MyIdentifier < Base
|
|
16
16
|
# include Checkable
|
|
17
17
|
#
|
|
18
|
-
# ID_REGEX = /\A(?<identifier>[A-Z]{6})(?<
|
|
18
|
+
# ID_REGEX = /\A(?<identifier>[A-Z]{6})(?<checksum>\d)?\z/x
|
|
19
19
|
#
|
|
20
20
|
# def initialize(id)
|
|
21
21
|
# parts = parse(id)
|
|
22
22
|
# @identifier = parts[:identifier]
|
|
23
|
-
# @
|
|
23
|
+
# @checksum = parts[:checksum]&.to_i
|
|
24
24
|
# end
|
|
25
25
|
#
|
|
26
|
-
# def
|
|
26
|
+
# def calculate_checksum
|
|
27
27
|
# validate_format_for_calculation!
|
|
28
28
|
# mod10(some_algorithm)
|
|
29
29
|
# end
|
|
30
30
|
# end
|
|
31
31
|
#
|
|
32
|
-
# @example Implementing a non-
|
|
32
|
+
# @example Implementing a non-checksum identifier
|
|
33
33
|
# class SimpleId < Base
|
|
34
34
|
# ID_REGEX = /\A(?<identifier>[A-Z]{6})\z/x
|
|
35
35
|
#
|
|
@@ -46,7 +46,7 @@ module SecID
|
|
|
46
46
|
# @return [String] the original input after normalization (stripped and uppercased)
|
|
47
47
|
attr_reader :full_id
|
|
48
48
|
|
|
49
|
-
# @return [String, nil] the main identifier portion (without
|
|
49
|
+
# @return [String, nil] the main identifier portion (without checksum)
|
|
50
50
|
attr_reader :identifier
|
|
51
51
|
|
|
52
52
|
class << self
|
|
@@ -55,15 +55,15 @@ module SecID
|
|
|
55
55
|
# @return [Symbol] the registry key (e.g. :isin, :cusip)
|
|
56
56
|
def type_key = @type_key ||= short_name.downcase.to_sym
|
|
57
57
|
|
|
58
|
-
# Composite sort key ranking detection specificity:
|
|
58
|
+
# Composite sort key ranking detection specificity: checksum types first,
|
|
59
59
|
# then narrower length range, then registration order. A class the registry
|
|
60
60
|
# never saw sorts last.
|
|
61
61
|
#
|
|
62
62
|
# @api private
|
|
63
|
-
# @return [Array] frozen [
|
|
63
|
+
# @return [Array] frozen [checksum rank, length specificity, registration order]
|
|
64
64
|
def detection_priority
|
|
65
65
|
@detection_priority ||=
|
|
66
|
-
[
|
|
66
|
+
[has_checksum? ? 0 : 1, length_specificity, SecID.identifiers.index(self) || Float::INFINITY].freeze
|
|
67
67
|
end
|
|
68
68
|
|
|
69
69
|
# @return [String] the unqualified class name (e.g. "ISIN", "CUSIP")
|
|
@@ -89,11 +89,19 @@ module SecID
|
|
|
89
89
|
# @return [String] a representative valid identifier string
|
|
90
90
|
def example = self::EXAMPLE
|
|
91
91
|
|
|
92
|
-
# @return [Boolean] true if this identifier type uses a
|
|
93
|
-
def
|
|
94
|
-
return @
|
|
92
|
+
# @return [Boolean] true if this identifier type uses a checksum
|
|
93
|
+
def has_checksum?
|
|
94
|
+
return @has_checksum if defined?(@has_checksum)
|
|
95
|
+
|
|
96
|
+
@has_checksum = ancestors.include?(SecID::Checkable)
|
|
97
|
+
end
|
|
95
98
|
|
|
96
|
-
|
|
99
|
+
# @deprecated Use {.has_checksum?}. Kept as a v7 bridge; removed in v8.
|
|
100
|
+
#
|
|
101
|
+
# @return [Boolean]
|
|
102
|
+
def has_check_digit?
|
|
103
|
+
SecID::Deprecation.warn(old: 'has_check_digit?', new: 'has_checksum?')
|
|
104
|
+
has_checksum?
|
|
97
105
|
end
|
|
98
106
|
end
|
|
99
107
|
|
|
@@ -134,7 +142,7 @@ module SecID
|
|
|
134
142
|
full_id: full_id,
|
|
135
143
|
normalized: valid? ? normalized : nil,
|
|
136
144
|
valid: valid?,
|
|
137
|
-
components:
|
|
145
|
+
components: components_with_deprecation_bridge
|
|
138
146
|
}
|
|
139
147
|
end
|
|
140
148
|
|
|
@@ -157,7 +165,7 @@ module SecID
|
|
|
157
165
|
# in SecID::ISIN[country_code:, nsin:] then [country_code, nsin]
|
|
158
166
|
# in nil then :invalid
|
|
159
167
|
# end #=> ['US', '594918104']
|
|
160
|
-
def deconstruct_keys(_keys) =
|
|
168
|
+
def deconstruct_keys(_keys) = components_with_deprecation_bridge
|
|
161
169
|
|
|
162
170
|
protected
|
|
163
171
|
|
|
@@ -168,6 +176,17 @@ module SecID
|
|
|
168
176
|
|
|
169
177
|
private
|
|
170
178
|
|
|
179
|
+
# Mirrors the canonical `:checksum` components key onto the deprecated `:check_digit`
|
|
180
|
+
# key for the v7 bridge, when a checksum is present. The mirror reads `:checksum`, so the
|
|
181
|
+
# deprecated `check_digit` reader never fires internally. Removed in v8 (revert `to_h` and
|
|
182
|
+
# `deconstruct_keys` to call `components` directly).
|
|
183
|
+
#
|
|
184
|
+
# @return [Hash] the parsed components, with `:check_digit` added when `:checksum` is present
|
|
185
|
+
def components_with_deprecation_bridge
|
|
186
|
+
parsed = components
|
|
187
|
+
parsed.key?(:checksum) ? parsed.merge(check_digit: parsed[:checksum]) : parsed
|
|
188
|
+
end
|
|
189
|
+
|
|
171
190
|
# @return [Hash]
|
|
172
191
|
def components
|
|
173
192
|
{}
|
data/lib/sec_id/cei.rb
CHANGED
|
@@ -4,7 +4,7 @@ module SecID
|
|
|
4
4
|
# CUSIP Entity Identifier (CEI) - a 10-character alphanumeric code that identifies
|
|
5
5
|
# legal entities in the syndicated loan market.
|
|
6
6
|
#
|
|
7
|
-
# Format: 1 alpha + 1 digit + 7 alphanumeric + 1
|
|
7
|
+
# Format: 1 alpha + 1 digit + 7 alphanumeric + 1 checksum
|
|
8
8
|
#
|
|
9
9
|
# @see https://www.cusip.com/identifiers.html
|
|
10
10
|
#
|
|
@@ -12,6 +12,7 @@ module SecID
|
|
|
12
12
|
# SecID::CEI.valid?('A0BCDEFGH1') #=> true
|
|
13
13
|
class CEI < Base
|
|
14
14
|
include Checkable
|
|
15
|
+
include Suggestable
|
|
15
16
|
|
|
16
17
|
# Human-readable name of the standard.
|
|
17
18
|
FULL_NAME = 'CUSIP Entity Identifier'
|
|
@@ -28,7 +29,7 @@ module SecID
|
|
|
28
29
|
(?<prefix>[A-Z])
|
|
29
30
|
(?<numeric>[0-9])
|
|
30
31
|
(?<entity_id>[A-Z0-9]{7}))
|
|
31
|
-
(?<
|
|
32
|
+
(?<checksum>\d)?
|
|
32
33
|
\z/x
|
|
33
34
|
|
|
34
35
|
# @return [String, nil] the first character (alphabetic)
|
|
@@ -47,12 +48,12 @@ module SecID
|
|
|
47
48
|
@prefix = cei_parts[:prefix]
|
|
48
49
|
@numeric = cei_parts[:numeric]
|
|
49
50
|
@entity_id = cei_parts[:entity_id]
|
|
50
|
-
@
|
|
51
|
+
@checksum = cei_parts[:checksum]&.to_i
|
|
51
52
|
end
|
|
52
53
|
|
|
53
|
-
# @return [Integer] the calculated
|
|
54
|
+
# @return [Integer] the calculated checksum (0-9)
|
|
54
55
|
# @raise [InvalidFormatError] if the CEI format is invalid
|
|
55
|
-
def
|
|
56
|
+
def calculate_checksum
|
|
56
57
|
validate_format_for_calculation!
|
|
57
58
|
mod10(luhn_sum_double_add_double(reversed_digits_single(identifier)))
|
|
58
59
|
end
|
|
@@ -60,7 +61,7 @@ module SecID
|
|
|
60
61
|
# Generates a random CEI body: 1 letter + 1 digit + 7 alphanumeric characters.
|
|
61
62
|
#
|
|
62
63
|
# @param random [Random] source of randomness
|
|
63
|
-
# @return [String] a 9-character CEI body without
|
|
64
|
+
# @return [String] a 9-character CEI body without checksum
|
|
64
65
|
def self.generate_body(random)
|
|
65
66
|
"#{random_string(ALPHA, 1, random: random)}#{random_string(DIGITS, 1, random: random)}" \
|
|
66
67
|
"#{random_string(ALPHANUMERIC, 7, random: random)}"
|
|
@@ -70,6 +71,6 @@ module SecID
|
|
|
70
71
|
private
|
|
71
72
|
|
|
72
73
|
# @return [Hash]
|
|
73
|
-
def components = { prefix:, numeric:, entity_id:,
|
|
74
|
+
def components = { prefix:, numeric:, entity_id:, checksum: }
|
|
74
75
|
end
|
|
75
76
|
end
|
data/lib/sec_id/cik.rb
CHANGED
|
@@ -4,7 +4,7 @@ module SecID
|
|
|
4
4
|
# Central Index Key (CIK) - SEC identifier for entities filing with the SEC.
|
|
5
5
|
# A 1-10 digit number that uniquely identifies entities in SEC systems.
|
|
6
6
|
#
|
|
7
|
-
# @note CIK identifiers have no
|
|
7
|
+
# @note CIK identifiers have no checksum and validation is based solely on format.
|
|
8
8
|
#
|
|
9
9
|
# @see https://en.wikipedia.org/wiki/Central_Index_Key
|
|
10
10
|
#
|
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module SecID
|
|
4
|
-
# Provides
|
|
5
|
-
# Include this module in classes that have a
|
|
4
|
+
# Provides checksum validation and calculation for securities identifiers.
|
|
5
|
+
# Include this module in classes that have a checksum as part of their format.
|
|
6
6
|
#
|
|
7
7
|
# Including classes must implement:
|
|
8
|
-
# - `
|
|
8
|
+
# - `calculate_checksum` method that returns the calculated checksum value
|
|
9
9
|
#
|
|
10
10
|
# This module provides:
|
|
11
11
|
# - Character-to-digit mapping constants
|
|
12
|
-
# - Luhn algorithm variants for
|
|
13
|
-
# - `valid?` override that validates format and
|
|
12
|
+
# - Luhn algorithm variants for checksum calculation
|
|
13
|
+
# - `valid?` override that validates format and checksum
|
|
14
14
|
# - `restore` method returning full identifier string without mutation
|
|
15
|
-
# - `restore!` method to calculate and set the
|
|
16
|
-
# - `
|
|
17
|
-
# - Class-level convenience methods: `restore`, `restore!`, `
|
|
15
|
+
# - `restore!` method to calculate and set the checksum, returning `self`
|
|
16
|
+
# - `checksum` attribute
|
|
17
|
+
# - Class-level convenience methods: `restore`, `restore!`, `checksum`
|
|
18
18
|
#
|
|
19
19
|
# @example Including in an identifier class
|
|
20
20
|
# class MyIdentifier < Base
|
|
21
21
|
# include Checkable
|
|
22
22
|
#
|
|
23
|
-
# def
|
|
23
|
+
# def calculate_checksum
|
|
24
24
|
# validate_format_for_calculation!
|
|
25
25
|
# mod10(luhn_sum_standard(reversed_digits_multi(identifier)))
|
|
26
26
|
# end
|
|
@@ -30,7 +30,7 @@ module SecID
|
|
|
30
30
|
module Checkable
|
|
31
31
|
# Character-to-digit mapping for Luhn algorithm variants.
|
|
32
32
|
# Maps alphanumeric characters to digit arrays for multi-digit expansion.
|
|
33
|
-
# Used by ISIN for
|
|
33
|
+
# Used by ISIN for checksum calculation.
|
|
34
34
|
CHAR_TO_DIGITS = {
|
|
35
35
|
'0' => 0, '1' => 1, '2' => 2, '3' => 3, '4' => 4,
|
|
36
36
|
'5' => 5, '6' => 6, '7' => 7, '8' => 8, '9' => 9,
|
|
@@ -44,7 +44,7 @@ module SecID
|
|
|
44
44
|
|
|
45
45
|
# Character-to-digit mapping for single-digit conversion.
|
|
46
46
|
# Maps alphanumeric characters to values 0-38 (A=10, B=11, ..., Z=35, *=36, @=37, #=38).
|
|
47
|
-
# Used by CUSIP, FIGI, SEDOL, LEI, and IBAN for
|
|
47
|
+
# Used by CUSIP, FIGI, SEDOL, LEI, and IBAN for checksum calculations.
|
|
48
48
|
CHAR_TO_DIGIT = {
|
|
49
49
|
'0' => 0, '1' => 1, '2' => 2, '3' => 3, '4' => 4,
|
|
50
50
|
'5' => 5, '6' => 6, '7' => 7, '8' => 8, '9' => 9,
|
|
@@ -58,75 +58,102 @@ module SecID
|
|
|
58
58
|
|
|
59
59
|
# @api private
|
|
60
60
|
def self.included(base)
|
|
61
|
-
base.attr_reader :
|
|
61
|
+
base.attr_reader :checksum
|
|
62
62
|
base.extend(ClassMethods)
|
|
63
63
|
end
|
|
64
64
|
|
|
65
65
|
# Class methods added when Checkable is included.
|
|
66
66
|
module ClassMethods
|
|
67
|
-
# Returns the full identifier string with correct
|
|
67
|
+
# Returns the full identifier string with correct checksum.
|
|
68
68
|
#
|
|
69
|
-
# @param
|
|
70
|
-
# @return [String] the full identifier with correct
|
|
69
|
+
# @param id_without_checksum [String] identifier without or with incorrect checksum
|
|
70
|
+
# @return [String] the full identifier with correct checksum
|
|
71
71
|
# @raise [InvalidFormatError] if the identifier format is invalid
|
|
72
|
-
def restore(
|
|
73
|
-
new(
|
|
72
|
+
def restore(id_without_checksum)
|
|
73
|
+
new(id_without_checksum).restore
|
|
74
74
|
end
|
|
75
75
|
|
|
76
|
-
# Restores (calculates) the
|
|
76
|
+
# Restores (calculates) the checksum and returns the instance.
|
|
77
77
|
#
|
|
78
|
-
# @param
|
|
79
|
-
# @return [self] the restored instance with correct
|
|
78
|
+
# @param id_without_checksum [String] identifier without or with incorrect checksum
|
|
79
|
+
# @return [self] the restored instance with correct checksum
|
|
80
80
|
# @raise [InvalidFormatError] if the identifier format is invalid
|
|
81
|
-
def restore!(
|
|
82
|
-
new(
|
|
81
|
+
def restore!(id_without_checksum)
|
|
82
|
+
new(id_without_checksum).restore!
|
|
83
83
|
end
|
|
84
84
|
|
|
85
|
-
# @param id [String] the identifier to calculate
|
|
86
|
-
# @return [Integer, String] the calculated
|
|
85
|
+
# @param id [String] the identifier to calculate checksum for
|
|
86
|
+
# @return [Integer, String] the calculated checksum
|
|
87
|
+
# @raise [InvalidFormatError] if the identifier format is invalid
|
|
88
|
+
def checksum(id)
|
|
89
|
+
new(id).calculate_checksum
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @deprecated Use {.checksum}. Kept as a v7 bridge; removed in v8.
|
|
93
|
+
#
|
|
94
|
+
# @param id [String] the identifier to calculate the checksum for
|
|
95
|
+
# @return [Integer, String] the calculated checksum
|
|
87
96
|
# @raise [InvalidFormatError] if the identifier format is invalid
|
|
88
97
|
def check_digit(id)
|
|
89
|
-
new
|
|
98
|
+
SecID::Deprecation.warn(old: 'check_digit', new: 'checksum')
|
|
99
|
+
checksum(id)
|
|
90
100
|
end
|
|
91
101
|
end
|
|
92
102
|
|
|
93
|
-
# Validates format and
|
|
103
|
+
# Validates format and checksum.
|
|
94
104
|
#
|
|
95
105
|
# @return [Boolean]
|
|
96
106
|
def valid?
|
|
97
|
-
super &&
|
|
107
|
+
super && checksum == calculate_checksum
|
|
98
108
|
end
|
|
99
109
|
|
|
100
|
-
# Returns the full identifier string with correct
|
|
110
|
+
# Returns the full identifier string with correct checksum without mutation.
|
|
101
111
|
#
|
|
102
|
-
# @return [String] the full identifier with correct
|
|
112
|
+
# @return [String] the full identifier with correct checksum
|
|
103
113
|
# @raise [InvalidFormatError] if the identifier format is invalid
|
|
104
114
|
def restore
|
|
105
|
-
"#{identifier}#{
|
|
115
|
+
"#{identifier}#{calculate_checksum.to_s.rjust(checksum_width, '0')}"
|
|
106
116
|
end
|
|
107
117
|
|
|
108
|
-
# Calculates and sets the
|
|
118
|
+
# Calculates and sets the checksum, updating full_id.
|
|
109
119
|
#
|
|
110
120
|
# @return [self]
|
|
111
121
|
# @raise [InvalidFormatError] if the identifier format is invalid
|
|
112
122
|
def restore!
|
|
113
|
-
@
|
|
123
|
+
@checksum = calculate_checksum
|
|
114
124
|
@full_id = to_s
|
|
115
125
|
self
|
|
116
126
|
end
|
|
117
127
|
|
|
118
|
-
# Subclasses must override this method to implement their
|
|
128
|
+
# Subclasses must override this method to implement their checksum algorithm.
|
|
119
129
|
#
|
|
120
|
-
# @return [Integer, String] the calculated
|
|
130
|
+
# @return [Integer, String] the calculated checksum
|
|
121
131
|
# @raise [NotImplementedError] if subclass doesn't implement
|
|
122
132
|
# @raise [InvalidFormatError] if the identifier format is invalid
|
|
123
|
-
def
|
|
133
|
+
def calculate_checksum
|
|
124
134
|
raise NotImplementedError
|
|
125
135
|
end
|
|
126
136
|
|
|
127
137
|
# @return [String]
|
|
128
138
|
def to_s
|
|
129
|
-
"#{identifier}#{
|
|
139
|
+
"#{identifier}#{checksum&.to_s&.rjust(checksum_width, '0')}"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# @deprecated Use {#checksum}. Kept as a v7 bridge; removed in v8.
|
|
143
|
+
#
|
|
144
|
+
# @return [Integer, String]
|
|
145
|
+
def check_digit
|
|
146
|
+
SecID::Deprecation.warn(old: 'check_digit', new: 'checksum')
|
|
147
|
+
checksum
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# @deprecated Use {#calculate_checksum}. Kept as a v7 bridge; removed in v8.
|
|
151
|
+
#
|
|
152
|
+
# @return [Integer, String]
|
|
153
|
+
# @raise [InvalidFormatError] if the identifier format is invalid
|
|
154
|
+
def calculate_check_digit
|
|
155
|
+
SecID::Deprecation.warn(old: 'calculate_check_digit', new: 'calculate_checksum')
|
|
156
|
+
calculate_checksum
|
|
130
157
|
end
|
|
131
158
|
|
|
132
159
|
private
|
|
@@ -188,27 +215,27 @@ module SecID
|
|
|
188
215
|
id.each_char.flat_map { |c| CHAR_TO_DIGITS.fetch(c) }.reverse!
|
|
189
216
|
end
|
|
190
217
|
|
|
191
|
-
# Returns error codes including
|
|
218
|
+
# Returns error codes including checksum validation.
|
|
192
219
|
#
|
|
193
220
|
# @return [Array<Symbol>]
|
|
194
221
|
def error_codes
|
|
195
222
|
return detect_errors unless valid_format?
|
|
196
|
-
return [:
|
|
223
|
+
return [:invalid_checksum] unless checksum == calculate_checksum
|
|
197
224
|
|
|
198
225
|
[]
|
|
199
226
|
end
|
|
200
227
|
|
|
201
228
|
# @return [Integer]
|
|
202
|
-
def
|
|
229
|
+
def checksum_width
|
|
203
230
|
1
|
|
204
231
|
end
|
|
205
232
|
|
|
206
233
|
# @param code [Symbol]
|
|
207
234
|
# @return [String]
|
|
208
235
|
def validation_message(code)
|
|
209
|
-
if code == :
|
|
210
|
-
fmt = ->(cd) { cd.to_s.rjust(
|
|
211
|
-
return "
|
|
236
|
+
if code == :invalid_checksum
|
|
237
|
+
fmt = ->(cd) { cd.to_s.rjust(checksum_width, '0') }
|
|
238
|
+
return "Checksum '#{fmt[checksum]}' is invalid, expected '#{fmt[calculate_checksum]}'"
|
|
212
239
|
end
|
|
213
240
|
|
|
214
241
|
super
|
|
@@ -219,11 +246,11 @@ module SecID
|
|
|
219
246
|
def validate_format_for_calculation!
|
|
220
247
|
return if valid_format?
|
|
221
248
|
|
|
222
|
-
raise InvalidFormatError, "#{self.class.name} '#{full_id}' is invalid and
|
|
249
|
+
raise InvalidFormatError, "#{self.class.name} '#{full_id}' is invalid and checksum cannot be calculated!"
|
|
223
250
|
end
|
|
224
251
|
|
|
225
|
-
# @param sum [Integer] the sum to calculate
|
|
226
|
-
# @return [Integer]
|
|
252
|
+
# @param sum [Integer] the sum to calculate checksum from
|
|
253
|
+
# @return [Integer] checksum (0-9)
|
|
227
254
|
def mod10(sum)
|
|
228
255
|
(10 - (sum % 10)) % 10
|
|
229
256
|
end
|
|
@@ -235,9 +262,26 @@ module SecID
|
|
|
235
262
|
end
|
|
236
263
|
|
|
237
264
|
# @param numeric_string [String] numeric string representation
|
|
238
|
-
# @return [Integer]
|
|
265
|
+
# @return [Integer] checksum value (1-98)
|
|
239
266
|
def mod97(numeric_string)
|
|
240
267
|
98 - (numeric_string.to_i % 97)
|
|
241
268
|
end
|
|
269
|
+
|
|
270
|
+
# ISO 7064 hybrid MOD 31,30 check character over a base string, parameterized
|
|
271
|
+
# by the type's 30-symbol alphabet. Shared by DTI and UPI.
|
|
272
|
+
#
|
|
273
|
+
# @param base [String] the base string (identifier without check character)
|
|
274
|
+
# @param alphabet [Array<String>] the 30-symbol alphabet ordered by check value (0-29)
|
|
275
|
+
# @param alphabet_value [Hash{String => Integer}] maps each alphabet character to its value
|
|
276
|
+
# @return [String] the single computed check character
|
|
277
|
+
def mod31_30_check_char(base, alphabet, alphabet_value)
|
|
278
|
+
perm = 30
|
|
279
|
+
base.each_char do |c|
|
|
280
|
+
s = (perm + alphabet_value.fetch(c)) % 30
|
|
281
|
+
s = 30 if s.zero?
|
|
282
|
+
perm = (s * 2) % 31
|
|
283
|
+
end
|
|
284
|
+
alphabet.fetch((31 - perm) % 30)
|
|
285
|
+
end
|
|
242
286
|
end
|
|
243
287
|
end
|
|
@@ -4,9 +4,9 @@ module SecID
|
|
|
4
4
|
# Provides generation of new, format-valid identifiers for use as test fixtures.
|
|
5
5
|
#
|
|
6
6
|
# Including classes define a class-level `generate_body(random)` returning a valid
|
|
7
|
-
# body (the identifier without its
|
|
8
|
-
# builds an instance from that body and restores the
|
|
9
|
-
# Types whose shape is not "body (+
|
|
7
|
+
# body (the identifier without its checksum). The default {ClassMethods#generate}
|
|
8
|
+
# builds an instance from that body and restores the checksum for checksum types.
|
|
9
|
+
# Types whose shape is not "body (+ checksum)" compose the full identifier in
|
|
10
10
|
# `generate_body` (CFI, FISN) or override `generate` entirely (OCC).
|
|
11
11
|
#
|
|
12
12
|
# @note Generated identifiers are valid in format only — they are not real,
|
|
@@ -44,12 +44,12 @@ module SecID
|
|
|
44
44
|
# @return [self] a generated instance of the identifier type (e.g. {SecID::ISIN}) for which `valid?` is true
|
|
45
45
|
def generate(random: Random.new)
|
|
46
46
|
instance = new(generate_body(random))
|
|
47
|
-
|
|
47
|
+
has_checksum? ? instance.restore! : instance
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
private
|
|
51
51
|
|
|
52
|
-
# Subclasses must implement this to return a valid body (identifier without
|
|
52
|
+
# Subclasses must implement this to return a valid body (identifier without checksum),
|
|
53
53
|
# unless they override {#generate} entirely (as OCC does).
|
|
54
54
|
#
|
|
55
55
|
# @param _random [Random] source of randomness
|
|
@@ -19,7 +19,7 @@ module SecID
|
|
|
19
19
|
#
|
|
20
20
|
# @param id [String, #to_s] the identifier to normalize
|
|
21
21
|
# @return [String] the normalized identifier
|
|
22
|
-
# @raise [InvalidFormatError,
|
|
22
|
+
# @raise [InvalidFormatError, InvalidChecksumError, InvalidStructureError]
|
|
23
23
|
def normalize(id)
|
|
24
24
|
cleaned = id.to_s.strip.gsub(self::SEPARATORS, '')
|
|
25
25
|
new(cleaned.upcase).normalized
|
|
@@ -38,7 +38,7 @@ module SecID
|
|
|
38
38
|
# Returns the canonical normalized form of this identifier.
|
|
39
39
|
#
|
|
40
40
|
# @return [String]
|
|
41
|
-
# @raise [InvalidFormatError,
|
|
41
|
+
# @raise [InvalidFormatError, InvalidChecksumError, InvalidStructureError]
|
|
42
42
|
def normalized
|
|
43
43
|
validate!
|
|
44
44
|
to_s
|
|
@@ -46,13 +46,13 @@ module SecID
|
|
|
46
46
|
|
|
47
47
|
# @!method normalize
|
|
48
48
|
# @return [String]
|
|
49
|
-
# @raise [InvalidFormatError,
|
|
49
|
+
# @raise [InvalidFormatError, InvalidChecksumError, InvalidStructureError]
|
|
50
50
|
alias normalize normalized
|
|
51
51
|
|
|
52
52
|
# Normalizes this identifier in place, updating {#full_id}.
|
|
53
53
|
#
|
|
54
54
|
# @return [self]
|
|
55
|
-
# @raise [InvalidFormatError,
|
|
55
|
+
# @raise [InvalidFormatError, InvalidChecksumError, InvalidStructureError]
|
|
56
56
|
def normalize!
|
|
57
57
|
@full_id = normalized
|
|
58
58
|
self
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SecID
|
|
4
|
+
# Repair engine for checksum-failing identifiers: enumerates plausible single-character
|
|
5
|
+
# edits (homoglyph/OCR substitutions and adjacent transpositions), keeps only those that
|
|
6
|
+
# re-validate, and returns them as confidence-ranked {Suggestion} objects.
|
|
7
|
+
#
|
|
8
|
+
# Included by the 9 checksum types. The candidate space is the human-error net —
|
|
9
|
+
# {HOMOGLYPHS} plus adjacent transpositions — never a full coincidental net. `valid?`
|
|
10
|
+
# is the oracle, so no false candidate ever escapes; the {HOMOGLYPHS} table bounds recall.
|
|
11
|
+
#
|
|
12
|
+
# @see SecID::Suggestion
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# SecID::ISIN.suggest('US5949181O45')
|
|
16
|
+
# #=> [#<SecID::Suggestion edit=:substitution ...>, #<SecID::Suggestion edit=:checksum ...>]
|
|
17
|
+
module Suggestable
|
|
18
|
+
# Homoglyph / OCR confusion table (KTD5 seed): each character maps to the characters
|
|
19
|
+
# it is commonly mistyped as or misread from. A bidirectional expansion of the seed
|
|
20
|
+
# pairs {0/O, 0/Q, 0/D, 1/I, 1/L, I/L, 2/Z, 5/S, 6/G, 8/B}. Membership bounds recall —
|
|
21
|
+
# widen it (see benchmark/suggest_precision.rb) to raise it.
|
|
22
|
+
HOMOGLYPHS = {
|
|
23
|
+
'0' => %w[O Q D], 'O' => %w[0], 'Q' => %w[0], 'D' => %w[0],
|
|
24
|
+
'1' => %w[I L], 'I' => %w[1 L], 'L' => %w[1 I],
|
|
25
|
+
'2' => %w[Z], 'Z' => %w[2], '5' => %w[S], 'S' => %w[5],
|
|
26
|
+
'6' => %w[G], 'G' => %w[6], '8' => %w[B], 'B' => %w[8]
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
# Rank weight per edit kind: body substitutions first, then adjacent transpositions,
|
|
30
|
+
# then the checksum-recompute fallback last (R6/R7).
|
|
31
|
+
RANK = { substitution: 0, transposition: 1, checksum: 2 }.freeze
|
|
32
|
+
|
|
33
|
+
# @api private
|
|
34
|
+
def self.included(base)
|
|
35
|
+
base.extend(ClassMethods)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Class methods added when Suggestable is included.
|
|
39
|
+
module ClassMethods
|
|
40
|
+
# Returns confidence-ranked repair candidates for a checksum-failing identifier.
|
|
41
|
+
#
|
|
42
|
+
# @param str [String] the identifier string to repair
|
|
43
|
+
# @return [Array<Suggestion>] ranked candidates; empty when `str` is already valid
|
|
44
|
+
# or fails the type's format (wrong length or charset)
|
|
45
|
+
def suggest(str)
|
|
46
|
+
new(str).suggest
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Enumerates plausible edits and returns the re-validating ones, confidence-ranked.
|
|
51
|
+
#
|
|
52
|
+
# @return [Array<Suggestion>] ranked candidates; empty when already valid or unparseable
|
|
53
|
+
def suggest
|
|
54
|
+
return [] if valid?
|
|
55
|
+
return [] unless valid_format?
|
|
56
|
+
|
|
57
|
+
surviving = candidate_strings.uniq.map { |candidate| self.class.new(candidate) }.select(&:valid?)
|
|
58
|
+
surviving.filter_map { |candidate| classify(candidate) }.sort_by { |suggestion| RANK.fetch(suggestion.edit) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# Homoglyph substitutions + adjacent transpositions + the checksum recompute (R4, R6).
|
|
64
|
+
#
|
|
65
|
+
# @return [Array<String>] candidate identifier strings (unfiltered, may include duplicates)
|
|
66
|
+
def candidate_strings
|
|
67
|
+
homoglyph_candidates + transposition_candidates + [restore]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @return [Array<String>]
|
|
71
|
+
def homoglyph_candidates
|
|
72
|
+
full_id.each_char.with_index.flat_map do |char, index|
|
|
73
|
+
Array(HOMOGLYPHS[char]).map { |replacement| replace_at(full_id, index, replacement) }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @return [Array<String>]
|
|
78
|
+
def transposition_candidates
|
|
79
|
+
(0...(full_id.length - 1)).filter_map do |index|
|
|
80
|
+
swap_at(full_id, index) unless full_id[index] == full_id[index + 1]
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @param str [String]
|
|
85
|
+
# @param index [Integer]
|
|
86
|
+
# @param char [String] the replacement character
|
|
87
|
+
# @return [String]
|
|
88
|
+
def replace_at(str, index, char)
|
|
89
|
+
str.dup.tap { |copy| copy[index] = char }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @param str [String]
|
|
93
|
+
# @param index [Integer] left index of the adjacent pair to swap
|
|
94
|
+
# @return [String]
|
|
95
|
+
def swap_at(str, index)
|
|
96
|
+
str.dup.tap { |copy| copy[index], copy[index + 1] = copy[index + 1], copy[index] }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Tags a surviving candidate by comparing bodies then diffing the strings (KTD3):
|
|
100
|
+
# equal bodies mean only the check characters changed.
|
|
101
|
+
#
|
|
102
|
+
# @param candidate [SecID::Base] a re-validated candidate instance
|
|
103
|
+
# @return [Suggestion, nil] nil when the diff is neither a single substitution nor an adjacent swap
|
|
104
|
+
def classify(candidate)
|
|
105
|
+
return checksum_suggestion(candidate) if candidate.identifier == identifier
|
|
106
|
+
|
|
107
|
+
changed = changed_indices(candidate)
|
|
108
|
+
case changed.size
|
|
109
|
+
when 1 then substitution_suggestion(candidate, changed.first.to_i)
|
|
110
|
+
when 2 then transposition_suggestion(candidate, changed)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @param candidate [SecID::Base]
|
|
115
|
+
# @return [Array<Integer>] indices where the candidate's string differs from the input's
|
|
116
|
+
def changed_indices(candidate)
|
|
117
|
+
other = candidate.full_id
|
|
118
|
+
(0...full_id.length).reject { |index| full_id[index] == other[index] }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# @param candidate [SecID::Base]
|
|
122
|
+
# @param index [Integer]
|
|
123
|
+
# @return [Suggestion]
|
|
124
|
+
def substitution_suggestion(candidate, index)
|
|
125
|
+
Suggestion.new(
|
|
126
|
+
type: self.class.type_key, identifier: candidate, edit: :substitution,
|
|
127
|
+
position: index, from: full_id[index].to_s, to: candidate.full_id[index].to_s, confidence: :high
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# @param candidate [SecID::Base]
|
|
132
|
+
# @param indices [Array<Integer>] the two differing indices
|
|
133
|
+
# @return [Suggestion, nil] nil unless the two indices are an adjacent, mutual swap
|
|
134
|
+
def transposition_suggestion(candidate, indices)
|
|
135
|
+
left = indices.min.to_i
|
|
136
|
+
right = indices.max.to_i
|
|
137
|
+
return unless right == left + 1 && swap?(candidate, left, right)
|
|
138
|
+
|
|
139
|
+
Suggestion.new(
|
|
140
|
+
type: self.class.type_key, identifier: candidate, edit: :transposition,
|
|
141
|
+
position: left, from: full_id[left, 2].to_s, to: candidate.full_id[left, 2].to_s, confidence: :medium
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# @param candidate [SecID::Base]
|
|
146
|
+
# @param left [Integer]
|
|
147
|
+
# @param right [Integer]
|
|
148
|
+
# @return [Boolean] true when the two positions are a mutual swap of the input's characters
|
|
149
|
+
def swap?(candidate, left, right)
|
|
150
|
+
full_id[left] == candidate.full_id[right] && full_id[right] == candidate.full_id[left]
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# @param candidate [SecID::Base]
|
|
154
|
+
# @return [Suggestion]
|
|
155
|
+
def checksum_suggestion(candidate)
|
|
156
|
+
Suggestion.new(
|
|
157
|
+
type: self.class.type_key, identifier: candidate, edit: :checksum, position: nil,
|
|
158
|
+
from: rendered_checksum(checksum), to: rendered_checksum(candidate.checksum), confidence: nil
|
|
159
|
+
)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# @param value [Integer, String, nil] a checksum value
|
|
163
|
+
# @return [String] the check-character string, zero-padded to the checksum width ('' when absent)
|
|
164
|
+
def rendered_checksum(value)
|
|
165
|
+
value.nil? ? '' : value.to_s.rjust(checksum_width, '0')
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|