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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -0
  3. data/MIGRATION.md +147 -0
  4. data/README.md +108 -45
  5. data/lib/sec_id/base.rb +36 -17
  6. data/lib/sec_id/cei.rb +8 -7
  7. data/lib/sec_id/cik.rb +1 -1
  8. data/lib/sec_id/concerns/checkable.rb +90 -46
  9. data/lib/sec_id/concerns/generatable.rb +5 -5
  10. data/lib/sec_id/concerns/normalizable.rb +4 -4
  11. data/lib/sec_id/concerns/suggestable.rb +168 -0
  12. data/lib/sec_id/concerns/validatable.rb +7 -7
  13. data/lib/sec_id/cusip.rb +9 -8
  14. data/lib/sec_id/deprecation.rb +26 -0
  15. data/lib/sec_id/dti.rb +9 -14
  16. data/lib/sec_id/figi.rb +10 -9
  17. data/lib/sec_id/iban.rb +21 -20
  18. data/lib/sec_id/isin.rb +10 -9
  19. data/lib/sec_id/lei.rb +11 -10
  20. data/lib/sec_id/occ.rb +1 -1
  21. data/lib/sec_id/sedol.rb +12 -11
  22. data/lib/sec_id/suggestion.rb +59 -0
  23. data/lib/sec_id/upi.rb +77 -0
  24. data/lib/sec_id/valoren.rb +2 -2
  25. data/lib/sec_id/version.rb +1 -1
  26. data/lib/sec_id/wkn.rb +1 -1
  27. data/lib/sec_id.rb +43 -3
  28. data/sec_id.gemspec +5 -4
  29. data/sig/sec_id/base.rbs +16 -5
  30. data/sig/sec_id/cei.rbs +4 -0
  31. data/sig/sec_id/concerns/checkable.rbs +18 -7
  32. data/sig/sec_id/concerns/generatable.rbs +1 -1
  33. data/sig/sec_id/concerns/suggestable.rbs +32 -0
  34. data/sig/sec_id/concerns/validatable.rbs +1 -1
  35. data/sig/sec_id/cusip.rbs +4 -0
  36. data/sig/sec_id/deprecation.rbs +6 -0
  37. data/sig/sec_id/dti.rbs +6 -1
  38. data/sig/sec_id/figi.rbs +4 -0
  39. data/sig/sec_id/iban.rbs +7 -3
  40. data/sig/sec_id/isin.rbs +4 -0
  41. data/sig/sec_id/lei.rbs +5 -1
  42. data/sig/sec_id/sedol.rbs +4 -0
  43. data/sig/sec_id/suggestion.rbs +23 -0
  44. data/sig/sec_id/upi.rbs +29 -0
  45. data/sig/sec_id.rbs +7 -2
  46. 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 check digits should also include the Checkable concern,
12
- # which provides check-digit validation, calculation, and restoration.
11
+ # Subclasses with checksum should also include the Checkable concern,
12
+ # which provides checksum validation, calculation, and restoration.
13
13
  #
14
- # @example Implementing a check-digit identifier
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})(?<check_digit>\d)?\z/x
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
- # @check_digit = parts[:check_digit]&.to_i
23
+ # @checksum = parts[:checksum]&.to_i
24
24
  # end
25
25
  #
26
- # def calculate_check_digit
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-check-digit identifier
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 check digit)
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: check-digit types first,
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 [check-digit rank, length specificity, registration order]
63
+ # @return [Array] frozen [checksum rank, length specificity, registration order]
64
64
  def detection_priority
65
65
  @detection_priority ||=
66
- [has_check_digit? ? 0 : 1, length_specificity, SecID.identifiers.index(self) || Float::INFINITY].freeze
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 check digit
93
- def has_check_digit?
94
- return @has_check_digit if defined?(@has_check_digit)
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
- @has_check_digit = ancestors.include?(SecID::Checkable)
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: 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) = components
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 check digit
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
- (?<check_digit>\d)?
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
- @check_digit = cei_parts[:check_digit]&.to_i
51
+ @checksum = cei_parts[:checksum]&.to_i
51
52
  end
52
53
 
53
- # @return [Integer] the calculated check digit (0-9)
54
+ # @return [Integer] the calculated checksum (0-9)
54
55
  # @raise [InvalidFormatError] if the CEI format is invalid
55
- def calculate_check_digit
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 check digit
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:, check_digit: }
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 check digit and validation is based solely on format.
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 check-digit validation and calculation for securities identifiers.
5
- # Include this module in classes that have a check digit as part of their format.
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
- # - `calculate_check_digit` method that returns the calculated check digit value
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 check-digit calculation
13
- # - `valid?` override that validates format and check digit
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 check digit, returning `self`
16
- # - `check_digit` attribute
17
- # - Class-level convenience methods: `restore`, `restore!`, `check_digit`
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 calculate_check_digit
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 check-digit calculation.
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 check-digit calculations.
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 :check_digit
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 check digit.
67
+ # Returns the full identifier string with correct checksum.
68
68
  #
69
- # @param id_without_check_digit [String] identifier without or with incorrect check digit
70
- # @return [String] the full identifier with correct check digit
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(id_without_check_digit)
73
- new(id_without_check_digit).restore
72
+ def restore(id_without_checksum)
73
+ new(id_without_checksum).restore
74
74
  end
75
75
 
76
- # Restores (calculates) the check digit and returns the instance.
76
+ # Restores (calculates) the checksum and returns the instance.
77
77
  #
78
- # @param id_without_check_digit [String] identifier without or with incorrect check digit
79
- # @return [self] the restored instance with correct check digit
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!(id_without_check_digit)
82
- new(id_without_check_digit).restore!
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 check digit for
86
- # @return [Integer, String] the calculated check digit
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(id).calculate_check_digit
98
+ SecID::Deprecation.warn(old: 'check_digit', new: 'checksum')
99
+ checksum(id)
90
100
  end
91
101
  end
92
102
 
93
- # Validates format and check digit.
103
+ # Validates format and checksum.
94
104
  #
95
105
  # @return [Boolean]
96
106
  def valid?
97
- super && check_digit == calculate_check_digit
107
+ super && checksum == calculate_checksum
98
108
  end
99
109
 
100
- # Returns the full identifier string with correct check digit without mutation.
110
+ # Returns the full identifier string with correct checksum without mutation.
101
111
  #
102
- # @return [String] the full identifier with correct check digit
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}#{calculate_check_digit.to_s.rjust(check_digit_width, '0')}"
115
+ "#{identifier}#{calculate_checksum.to_s.rjust(checksum_width, '0')}"
106
116
  end
107
117
 
108
- # Calculates and sets the check digit, updating full_id.
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
- @check_digit = calculate_check_digit
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 check-digit algorithm.
128
+ # Subclasses must override this method to implement their checksum algorithm.
119
129
  #
120
- # @return [Integer, String] the calculated check digit
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 calculate_check_digit
133
+ def calculate_checksum
124
134
  raise NotImplementedError
125
135
  end
126
136
 
127
137
  # @return [String]
128
138
  def to_s
129
- "#{identifier}#{check_digit&.to_s&.rjust(check_digit_width, '0')}"
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 check digit validation.
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 [:invalid_check_digit] unless check_digit == calculate_check_digit
223
+ return [:invalid_checksum] unless checksum == calculate_checksum
197
224
 
198
225
  []
199
226
  end
200
227
 
201
228
  # @return [Integer]
202
- def check_digit_width
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 == :invalid_check_digit
210
- fmt = ->(cd) { cd.to_s.rjust(check_digit_width, '0') }
211
- return "Check digit '#{fmt[check_digit]}' is invalid, expected '#{fmt[calculate_check_digit]}'"
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 check-digit cannot be calculated!"
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 check digit from
226
- # @return [Integer] check digit (0-9)
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] check digit value (1-98)
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 check digit). The default {ClassMethods#generate}
8
- # builds an instance from that body and restores the check digit for check-digit types.
9
- # Types whose shape is not "body (+ check digit)" compose the full identifier in
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
- has_check_digit? ? instance.restore! : instance
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 check digit),
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, InvalidCheckDigitError, InvalidStructureError]
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, InvalidCheckDigitError, InvalidStructureError]
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, InvalidCheckDigitError, InvalidStructureError]
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, InvalidCheckDigitError, InvalidStructureError]
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