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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2c705053b66b513f6cbac21702cd4548f5f446669f9251db569d62693698ff64
4
- data.tar.gz: b792e03a67c57ead4064c392925771033c35b68fd984ad93aa5d932e37d1ea21
3
+ metadata.gz: 2ce3f9d548d4530dae973ee4ee4a7cf8e18898aaa36396423efe5ece7dd091f4
4
+ data.tar.gz: 5b7950a09cfc4afd59358b230fd659a9f26a1e58e50577ed16267f4763142a7f
5
5
  SHA512:
6
- metadata.gz: 51880f7c27744b9700e01bca80a6f3a9a436d76a843921fdc97a401e12ef0acb2011c4f67a19034c30ea90e59638de0b66cb8a949b32d2d896e28a4f7957c4ba
7
- data.tar.gz: 780d1cd07415046d2c5dfda3b6f548785ae0e244bdb294876440521f90475562000beaf493c88109d609e9c65fa83b94ad67c8509084174a84e52e6be9268543
6
+ metadata.gz: 34447319dc249c2fb3b7150cc45cc4a2aa8eed8e11ab16b726d9ee73d7aa2ad01daa2eed846f0cc788fb9f955488b161a91112ea6ec683268af7fa3fdf5267e0
7
+ data.tar.gz: 155fd8fe486ea96af91949ddd1adacb04310f50c820a2a5c1d98f1f55075545886c3935adfa5dacf2eedc9975ca7f1879c0e1140a8165dcd60736f24a0bcea94
data/CHANGELOG.md CHANGED
@@ -8,6 +8,26 @@ and [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/).
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [7.1.0] - 2026-07-15
12
+
13
+ ### Added
14
+
15
+ - `suggest` — a checksum-anchored diagnostic-repair verb that turns the checksum from a gatekeeper into a repair engine. For a structurally-valid but checksum-**failing** identifier, `SecID::<Type>.suggest(str)` and `SecID.suggest(str, types:)` enumerate the plausible single-character human errors — visual/OCR homoglyph substitutions (`O`↔`0`, `I`↔`1`, `5`↔`S`, `8`↔`B`, …) and adjacent transpositions — keep only those that re-validate (`valid?` is the oracle, so no false candidate ever escapes), and return them as confidence-ranked `SecID::Suggestion` value objects reporting *what changed* (edit kind, position, from/to characters, confidence tier). Available for all 9 checksum types (ISIN, CUSIP, SEDOL, FIGI, LEI, IBAN, CEI, DTI, UPI) through one shared `Suggestable` concern. Candidates rank `:high` (homoglyph) then `:medium` (transposition) then a `:checksum` recompute fallback last; there is no `:low` tier (coincidental substitutions are never generated), keeping results small and high-precision. `suggest` never mutates its input and never presents a candidate as an authoritative correction. Non-checksum types (CIK, OCC, WKN, Valoren, CFI, FISN, BIC) have no checksum oracle and are unsupported; `SecID.suggest` silently skips them. Known limitations: the mistyped character must be in the type's charset to be reachable (the vowel-free SEDOL/FIGI/DTI/UPI can't repair an `O`-for-`0`/`I`-for-`1` typo), only a single body error is in scope, and wrong-length input returns `[]`. In a seeded simulation (`benchmark/suggest_precision.rb`), every reachable single-error homoglyph or transposition that yields a checksum-failing identifier is recovered (the correct identifier is always among the returned candidates), the top-ranked body candidate ~89% of the time for homoglyph errors
16
+
17
+ ## [7.0.0] - 2026-07-14
18
+
19
+ ### Added
20
+
21
+ - UPI (ISO 4914, Unique Product Identifier) support via `SecID::UPI` — the gem's 16th identifier type, and the first offline UPI validator in any language. Validates the 12-character code (fixed `QZ` prefix, 9-character body, 1 check character) issued by the ANNA Derivatives Service Bureau for OTC-derivatives reporting (CFTC, EMIR, and other global mandates). The check character is computed fully offline via ISO 7064 hybrid MOD 31,30 over the same 30-symbol alphabet as DTI (digits plus consonants; vowels and `Y` never appear), pinned empirically against DSB-issued vectors — no DSB registry lookup or paywalled ISO 4914 spec required. Like DTI, `checksum`/`calculate_checksum` return a `String` rather than an `Integer`, since UPI check characters can be letters. A UPI shares the 12-character length bucket with ISIN and can double-detect (ISIN ranked first) when its digit check character also satisfies ISIN's Luhn checksum.
22
+
23
+ ### Changed
24
+
25
+ - BREAKING: Renamed the check-digit concept to **checksum** across the entire public API, because the old name was wrong on two axes — DTI's and UPI's check value is a `String` (it can be a letter, not a digit), and LEI and IBAN carry a two-character check value. The instance and class methods `check_digit` → `checksum`, `calculate_check_digit` → `calculate_checksum`, and `has_check_digit?` → `has_checksum?`; the error class `SecID::InvalidCheckDigitError` → `SecID::InvalidChecksumError`; the error code `:invalid_check_digit` → `:invalid_checksum` (a **hard flip with no dual emission** — dual would duplicate `errors.details` entries, so update any `errors.details`/`explain`/ActiveModel `details: true` matcher immediately); and the `:check_digit` components key → `:checksum` in `to_h`/`deconstruct_keys` across all nine checkable types. `restore`/`restore!` and the `Checkable` concern name are unchanged. No validation or checksum-arithmetic behavior changed: every identifier that was valid stays valid and every computed value is byte-identical. The old method names, the `InvalidCheckDigitError` constant, and the `:check_digit` components key remain as deprecated bridges through v7 (removed in v8); the error code is the only surface with no bridge. See MIGRATION.md for the full upgrade guide
26
+
27
+ ### Deprecated
28
+
29
+ - The pre-rename check-digit names, kept as v7 bridges and removed in v8: the `check_digit` / `calculate_check_digit` / `has_check_digit?` methods (instance and class level) — each warns via `Kernel#warn` on every call, visible at Ruby's default verbosity and silenceable with `-W0` / `$VERBOSE = nil` or an app-level `Warning` override; the `SecID::InvalidCheckDigitError` constant, a same-object alias of `InvalidChecksumError` so `rescue` under either name keeps working; and the `:check_digit` key, still present alongside `:checksum` in `components` / `to_h` / `deconstruct_keys`
30
+
11
31
  ## [6.1.0] - 2026-07-10
12
32
 
13
33
  ### Added
data/MIGRATION.md CHANGED
@@ -1,3 +1,150 @@
1
+ # Upgrading to SecID 7.0
2
+
3
+ This guide covers all breaking changes when upgrading from SecID 6.x to 7.0. They are all one
4
+ mechanical rename: the **check-digit** concept became **checksum** across the public API —
5
+ methods, the error class, the error code, the components key, and documentation vocabulary.
6
+ The name was inaccurate on two axes: DTI's and UPI's check value is a `String` (it can be a
7
+ letter, not a digit), and LEI and IBAN carry a two-character check value. `checksum` is
8
+ type- and count-agnostic.
9
+
10
+ Nothing about validation or checksum arithmetic changed — every previously valid identifier is
11
+ still valid and every computed value is byte-identical. Only names, one error code, and
12
+ deprecation warnings changed.
13
+
14
+ ## Quick Reference
15
+
16
+ | What changed | Before (6.x) | After (7.0) |
17
+ |---|---|---|
18
+ | Instance/class method | `isin.check_digit`, `SecID::ISIN.check_digit(id)` | `isin.checksum`, `SecID::ISIN.checksum(id)` |
19
+ | Calculation method | `isin.calculate_check_digit` | `isin.calculate_checksum` |
20
+ | Capability predicate | `SecID::ISIN.has_check_digit?` | `SecID::ISIN.has_checksum?` |
21
+ | Error class | `SecID::InvalidCheckDigitError` | `SecID::InvalidChecksumError` |
22
+ | **Error code (hard flip)** | `:invalid_check_digit` | `:invalid_checksum` |
23
+ | Components / `to_h` / pattern-match key | `{ check_digit: … }` | `{ checksum: … }` |
24
+
25
+ `restore` / `restore!` and the `Checkable` concern name are unchanged.
26
+
27
+ **Bridge:** the old method names, the `InvalidCheckDigitError` constant, and the `:check_digit`
28
+ components key all keep working through v7 (the methods warn on use); they are removed in v8.
29
+ **The one exception is the error code** — `:invalid_check_digit` flips hard to `:invalid_checksum`
30
+ at v7 with no bridge, so an error-code matcher must be updated immediately.
31
+
32
+ ## Step-by-Step
33
+
34
+ ### 1. Update Gemfile
35
+
36
+ ```ruby
37
+ gem 'sec_id', '~> 7.0'
38
+ ```
39
+
40
+ Then run `bundle update sec_id`.
41
+
42
+ ### 2. Rename method calls (deprecated aliases warn through v7)
43
+
44
+ ```ruby
45
+ # Before (6.x)
46
+ isin.check_digit
47
+ isin.calculate_check_digit
48
+ SecID::ISIN.check_digit('US594918104')
49
+ SecID::ISIN.has_check_digit?
50
+
51
+ # After (7.0)
52
+ isin.checksum
53
+ isin.calculate_checksum
54
+ SecID::ISIN.checksum('US594918104')
55
+ SecID::ISIN.has_checksum?
56
+ ```
57
+
58
+ The old names still work through v7 but emit a deprecation warning on every call and are removed
59
+ in v8. See step 6 to silence them if you cannot migrate every call site yet.
60
+
61
+ ### 3. Rename the rescued error class
62
+
63
+ ```ruby
64
+ # Before (6.x)
65
+ rescue SecID::InvalidCheckDigitError => e
66
+
67
+ # After (7.0)
68
+ rescue SecID::InvalidChecksumError => e
69
+ ```
70
+
71
+ `SecID::InvalidCheckDigitError` remains as a constant alias of `SecID::InvalidChecksumError`
72
+ through v7 (it is the *same class object*, so `rescue` under either name catches the same error),
73
+ and is removed in v8.
74
+
75
+ The constant alias emits **no** deprecation warning at runtime (it is a plain constant, not a
76
+ warned method), so `-W` output won't reveal your rescue sites — find them statically:
77
+
78
+ ```bash
79
+ grep -rEn 'InvalidCheckDigitError' app/ lib/ spec/
80
+ ```
81
+
82
+ ### 4. Update error-code matchers (hard flip — no bridge)
83
+
84
+ The `:invalid_check_digit` code in `errors.details` and `explain` output is replaced by
85
+ `:invalid_checksum` with **no dual emission** — emitting both would duplicate `errors.details`
86
+ entries. This is the only change with no v7 bridge, so update any matcher immediately.
87
+
88
+ ```ruby
89
+ # Before (6.x)
90
+ isin.errors.details.first[:error] == :invalid_check_digit
91
+ SecID.explain('US5949181040', types: [:isin])[:candidates].first[:errors]
92
+ # => [{ error: :invalid_check_digit, message: "Check digit '0' is invalid, expected '5'" }]
93
+
94
+ # After (7.0)
95
+ isin.errors.details.first[:error] == :invalid_checksum
96
+ SecID.explain('US5949181040', types: [:isin])[:candidates].first[:errors]
97
+ # => [{ error: :invalid_checksum, message: "Checksum '0' is invalid, expected '5'" }]
98
+ ```
99
+
100
+ If you use the opt-in ActiveModel/Rails validator with `details: true`, the surfaced reason
101
+ follows the same flip: an invalid checksum now reports `:invalid_checksum`.
102
+
103
+ ### 5. Migrate pattern matches and `to_h` readers before v8
104
+
105
+ `components` (and therefore `to_h` and `deconstruct_keys`) carries **both** `:checksum` and the
106
+ deprecated `:check_digit` key through v7, so existing pattern matches keep working. The
107
+ `:check_digit` key is removed in v8 — migrate to `:checksum` before then.
108
+
109
+ ```ruby
110
+ # Both work through v7, binding the same value:
111
+ isin => { check_digit: } # deprecated, removed in v8
112
+ isin => { checksum: } # canonical
113
+
114
+ isin.to_h[:components]
115
+ # => { country_code: 'US', nsin: '594918104', checksum: 5, check_digit: 5 }
116
+ ```
117
+
118
+ Reading the `:check_digit` key also emits **no** warning (the value mirrors `:checksum`), so grep
119
+ for the call sites rather than relying on stderr:
120
+
121
+ ```bash
122
+ grep -rEn ':check_digit\b|check_digit:' app/ lib/ spec/
123
+ ```
124
+
125
+ ### 6. Silencing the deprecation warnings
126
+
127
+ The method aliases warn via `Kernel#warn` on every call, visible at Ruby's default verbosity.
128
+ If you cannot migrate every call site before v7, silence them one of these ways:
129
+
130
+ ```ruby
131
+ # Process-wide, at the lowest verbosity (also silences other Ruby warnings):
132
+ $VERBOSE = nil # equivalent to running with -W0
133
+
134
+ # Or filter only SecID's deprecations with an app-level Warning override:
135
+ module SecIDDeprecationSilencer
136
+ def warn(message, category: nil, **)
137
+ super unless message.to_s.include?('SecID: `')
138
+ end
139
+ end
140
+ Warning.extend(SecIDDeprecationSilencer)
141
+ ```
142
+
143
+ The warnings are intentionally on by default (not routed through `Warning[:deprecated]`, which
144
+ Ruby leaves off) so the migration signal is visible without opting in.
145
+
146
+ ---
147
+
1
148
  # Upgrading to SecID 6.0
2
149
 
3
150
  This guide covers all breaking changes when upgrading from SecID 5.x to 6.0. Every one is
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # SecID [![Gem Version](https://badge.fury.io/rb/sec_id.svg)](https://rubygems.org/gems/sec_id) [![CI](https://github.com/svyatov/sec_id/actions/workflows/main.yml/badge.svg)](https://github.com/svyatov/sec_id/actions/workflows/main.yml) [![codecov](https://codecov.io/gh/svyatov/sec_id/graph/badge.svg?token=VV49EMQIIC)](https://codecov.io/gh/svyatov/sec_id) [![Documentation](https://img.shields.io/badge/docs-rubydoc.info-blue.svg)](https://rubydoc.info/gems/sec_id) [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.2-CC342D.svg)](https://www.ruby-lang.org) [![Types: RBS](https://img.shields.io/badge/types-RBS-8A2BE2.svg)](https://github.com/svyatov/sec_id/tree/main/sig)
2
2
 
3
- > A Ruby toolkit for securities identifiers — validate, parse, normalize, detect, convert, generate, and classify.
3
+ > A Ruby toolkit for securities identifiers — validate, parse, normalize, detect, convert, generate, classify, and repair.
4
4
 
5
5
  ## Table of Contents
6
6
 
@@ -13,6 +13,7 @@
13
13
  - [Structured Validation](#structured-validation) - detailed error codes and messages
14
14
  - [Pattern Matching](#pattern-matching) - destructure identifiers with `case/in`
15
15
  - [Generating Test Fixtures](#generating-test-fixtures) - produce valid identifiers for tests
16
+ - [Repairing Typos](#repairing-typos) - suggest corrections for checksum-failing identifiers
16
17
  - [ISIN](#isin) - International Securities Identification Number
17
18
  - [CUSIP](#cusip) - Committee on Uniform Securities Identification Procedures
18
19
  - [CEI](#cei) - CUSIP Entity Identifier
@@ -28,6 +29,7 @@
28
29
  - [FISN](#fisn) - Financial Instrument Short Name
29
30
  - [BIC](#bic) - Business Identifier Code / SWIFT code
30
31
  - [DTI](#dti) - Digital Token Identifier
32
+ - [UPI](#upi) - Unique Product Identifier
31
33
  - [ActiveModel / Rails Validator](#activemodel--rails-validator) - declarative `validates :isin, sec_id: {...}`
32
34
  - [Lookup Service Integration](#lookup-service-integration)
33
35
  - [Type Signatures (RBS)](#type-signatures-rbs)
@@ -46,7 +48,7 @@ Ruby 3.2+ is required.
46
48
  Add this line to your application's Gemfile:
47
49
 
48
50
  ```ruby
49
- gem 'sec_id', '~> 6.0'
51
+ gem 'sec_id', '~> 7.1'
50
52
  ```
51
53
 
52
54
  And then execute:
@@ -81,11 +83,11 @@ All identifier classes provide `valid?`, `errors`, `validate`, `validate!` metho
81
83
  ```ruby
82
84
  SecID::ISIN.new('US5949181045').to_h
83
85
  # => { type: :isin, full_id: 'US5949181045', normalized: 'US5949181045',
84
- # valid: true, components: { country_code: 'US', nsin: '594918104', check_digit: 5 } }
86
+ # valid: true, components: { country_code: 'US', nsin: '594918104', checksum: 5 } }
85
87
 
86
88
  SecID::ISIN.new('INVALID').to_h
87
89
  # => { type: :isin, full_id: 'INVALID', normalized: nil,
88
- # valid: false, components: { country_code: nil, nsin: nil, check_digit: nil } }
90
+ # valid: false, components: { country_code: nil, nsin: nil, checksum: nil } }
89
91
  ```
90
92
 
91
93
  **All identifiers** support value equality — two instances of the same type with the same normalized form are equal:
@@ -102,10 +104,10 @@ a.eql?(b) # => true
102
104
  Set.new([a, b]).size # => 1
103
105
  ```
104
106
 
105
- **Check-digit based identifiers** (ISIN, CUSIP, CEI, SEDOL, FIGI, LEI, IBAN, DTI) also provide:
106
- - `restore` / `.restore` - returns the full identifier string with correct check-digit (no mutation)
107
- - `restore!` / `.restore!` - restores check-digit in place and returns `self` / instance
108
- - `check_digit` / `calculate_check_digit` - calculates and returns the check-digit
107
+ **Checksum-based identifiers** (ISIN, CUSIP, CEI, SEDOL, FIGI, LEI, IBAN, DTI, UPI) also provide:
108
+ - `restore` / `.restore` - returns the full identifier string with correct checksum (no mutation)
109
+ - `restore!` / `.restore!` - restores checksum in place and returns `self` / instance
110
+ - `checksum` / `calculate_checksum` - calculates and returns the checksum
109
111
 
110
112
  ### Metadata Registry
111
113
 
@@ -126,14 +128,14 @@ SecID::ISIN.short_name # => "ISIN"
126
128
  SecID::ISIN.full_name # => "International Securities Identification Number"
127
129
  SecID::ISIN.id_length # => 12
128
130
  SecID::ISIN.example # => "US5949181045"
129
- SecID::ISIN.has_check_digit? # => true
131
+ SecID::ISIN.has_checksum? # => true
130
132
 
131
133
  # Filter with standard Ruby
132
- SecID.identifiers.select(&:has_check_digit?).map(&:short_name)
133
- # => ["ISIN", "CUSIP", "SEDOL", "FIGI", "LEI", "IBAN", "CEI", "DTI"]
134
+ SecID.identifiers.select(&:has_checksum?).map(&:short_name)
135
+ # => ["ISIN", "CUSIP", "SEDOL", "FIGI", "LEI", "IBAN", "CEI", "DTI", "UPI"]
134
136
 
135
137
  # Detect identifier type from an unknown string
136
- # Results are sorted by specificity: check-digit types first, then by length precision
138
+ # Results are sorted by specificity: checksum types first, then by length precision
137
139
  SecID.detect('US5949181045') # => [:isin]
138
140
  SecID.detect('037833100') # => [:cusip, :valoren, :cik]
139
141
  SecID.detect('APPLE INC/SH') # => [:fisn]
@@ -202,7 +204,7 @@ Understand why a string matches or doesn't match specific identifier types:
202
204
  result = SecID.explain('US5949181040')
203
205
  isin = result[:candidates].find { |c| c[:type] == :isin }
204
206
  isin[:valid] # => false
205
- isin[:errors].first[:error] # => :invalid_check_digit
207
+ isin[:errors].first[:error] # => :invalid_checksum
206
208
 
207
209
  # Filter to specific types
208
210
  SecID.explain('US5949181045', types: %i[isin cusip])
@@ -215,8 +217,8 @@ All identifier classes provide a Rails-like `#errors` API for detailed error rep
215
217
  ```ruby
216
218
  isin = SecID::ISIN.new('US5949181040')
217
219
  isin.errors.none? # => false
218
- isin.errors.messages # => ["Check digit '0' is invalid, expected '5'"]
219
- isin.errors.details # => [{ error: :invalid_check_digit, message: "Check digit '0' is invalid, expected '5'" }]
220
+ isin.errors.messages # => ["Checksum '0' is invalid, expected '5'"]
221
+ isin.errors.details # => [{ error: :invalid_checksum, message: "Checksum '0' is invalid, expected '5'" }]
220
222
  isin.errors.any? # => true
221
223
  isin.errors.empty? # => false
222
224
  isin.errors.size # => 1
@@ -233,7 +235,7 @@ SecID::ISIN.validate('US5949181040').errors # => #<SecID::Errors>
233
235
  - `:invalid_format` - correct length and characters but wrong structure
234
236
 
235
237
  **Type-specific error codes:**
236
- - `:invalid_check_digit` - check digit mismatch (ISIN, CUSIP, SEDOL, FIGI, LEI, IBAN, CEI, DTI)
238
+ - `:invalid_checksum` - checksum mismatch (ISIN, CUSIP, SEDOL, FIGI, LEI, IBAN, CEI, DTI, UPI)
237
239
  - `:invalid_prefix` - restricted FIGI prefix (FIGI)
238
240
  - `:invalid_category` - unknown CFI category code (CFI)
239
241
  - `:invalid_group` - unknown CFI group code for category (CFI)
@@ -255,7 +257,7 @@ SecID::ISIN.new('INVALID').validate!
255
257
  # => SecID::InvalidFormatError: Expected 12 characters, got 7
256
258
 
257
259
  SecID::ISIN.new('US5949181040').validate!
258
- # => SecID::InvalidCheckDigitError: Check digit '0' is invalid, expected '5'
260
+ # => SecID::InvalidChecksumError: Checksum '0' is invalid, expected '5'
259
261
 
260
262
  SecID::FIGI.new('BSG000BLNNH6').validate!
261
263
  # => SecID::InvalidStructureError: Prefix 'BS' is restricted
@@ -308,7 +310,7 @@ at all — `#to_h`'s envelope keys (`:type`, `:full_id`, `:normalized`, `:valid`
308
310
 
309
311
  ### Generating Test Fixtures
310
312
 
311
- Generate syntactically valid identifiers — with correct check digits where applicable — for use as test fixtures. Available per class and via the central dispatcher:
313
+ Generate syntactically valid identifiers — with correct checksum where applicable — for use as test fixtures. Available per class and via the central dispatcher:
312
314
 
313
315
  ```ruby
314
316
  SecID::ISIN.generate # => #<SecID::ISIN ...>
@@ -327,6 +329,41 @@ SecID::LEI.generate(random: Random.new(42)) == SecID::LEI.generate(random: Rando
327
329
  > randomly selected (from the values each standard permits) and do not map to real-world
328
330
  > instruments. Use them as test fixtures, not as references to actual securities.
329
331
 
332
+ ### Repairing Typos
333
+
334
+ Turn the checksum from a gatekeeper into a repair engine. For a checksum-**failing** identifier, `suggest` enumerates the plausible single-character human errors — visual/OCR homoglyph substitutions (`O`↔`0`, `I`↔`1`, `5`↔`S`, `8`↔`B`, …) and adjacent transpositions — keeps only the edits that re-validate, and returns them as confidence-ranked `SecID::Suggestion` candidates that report *what changed*. Available for all 9 checksum types (ISIN, CUSIP, SEDOL, FIGI, LEI, IBAN, CEI, DTI, UPI) per class and via the central dispatcher:
335
+
336
+ ```ruby
337
+ # A letter O typed where a 0 belongs — 'US5949181O45' should be 'US5949181045'
338
+ top = SecID::ISIN.suggest('US5949181O45').first
339
+ top.to_s # => 'US5949181045' (the corrected identifier)
340
+ top.edit # => :substitution
341
+ top.position # => 9
342
+ top.from # => 'O'
343
+ top.to # => '0'
344
+ top.confidence # => :high
345
+ top.identifier # => #<SecID::ISIN ...> (parsed and valid — call .country_code, .to_h, etc.)
346
+
347
+ # Module-level: infers every format-compatible checksum type (like parse / detect)
348
+ SecID.suggest('US5949181O45') # => [#<SecID::Suggestion type=:isin ...>, ...]
349
+ SecID.suggest('US5949181O45', types: [:isin]) # => restrict to specific types
350
+ ```
351
+
352
+ Each candidate carries the corrected `identifier` (a parsed, valid instance), the `edit` kind, its `position`, the `from`/`to` characters, and a `confidence` tier. Candidates are ranked by confidence: `:high` homoglyph substitutions first, then `:medium` adjacent transpositions, then the `:checksum` recompute (body assumed correct, wrong check character) last as a fallback hypothesis. **There is no `:low` tier** — coincidental substitutions that merely satisfy the checksum are never generated, keeping the result small and high-precision.
353
+
354
+ > **`suggest` returns candidates, never authoritative corrections, and never mutates its input.**
355
+ > Every returned candidate fully re-validates (`valid?` is the oracle), so no false candidate escapes —
356
+ > but the `confidence` tier is how *you* decide what to trust. This matters for financial identifiers.
357
+
358
+ Notes and limitations:
359
+
360
+ - **Never empty for structurally-valid input** — a parseable but checksum-failing identifier always yields at least the `:checksum` fallback. Only wrong-length or illegal-charset input (which fails the format gate) returns `[]`.
361
+ - **Vowel-free reachability** — SEDOL, FIGI, DTI, and UPI exclude vowels from their charset, so an `O`-for-`0` or `I`-for-`1` typo is unparseable and therefore unrepairable (it fails the format gate before enumeration). The mistyped character must be *in* the type's charset to be reachable.
362
+ - **Single body error only** — two or more wrong body characters, or a dropped/doubled character (insertion/deletion), are out of scope; such input returns only the `:checksum` fallback, never additional body candidates.
363
+ - **Non-checksum types are unsupported** — CIK, OCC, WKN, Valoren, CFI, FISN, and BIC have no checksum oracle, so they have no `suggest`; `SecID.suggest` silently skips them.
364
+
365
+ **Precision** (simulated over 1,000 seeded samples per checksum type — see [`benchmark/suggest_precision.rb`](benchmark/suggest_precision.rb)): every reachable, single-error homoglyph or transposition that yields a checksum-failing identifier is recovered — the correct identifier is **always** among the returned candidates (100%), and is the top-ranked body candidate ~89% of the time for homoglyph errors. In-charset homoglyph reachability averages ~96% (100% for the letter-permitting types, lower for the four vowel-free ones).
366
+
330
367
  ### ISIN
331
368
 
332
369
  > [International Securities Identification Number](https://en.wikipedia.org/wiki/International_Securities_Identification_Number) - a 12-character alphanumeric code that uniquely identifies a security.
@@ -336,18 +373,18 @@ SecID::LEI.generate(random: Random.new(42)) == SecID::LEI.generate(random: Rando
336
373
  SecID::ISIN.valid?('US5949181045') # => true
337
374
  SecID::ISIN.restore('US594918104') # => 'US5949181045'
338
375
  SecID::ISIN.restore!('US594918104') # => #<SecID::ISIN>
339
- SecID::ISIN.check_digit('US594918104') # => 5
376
+ SecID::ISIN.checksum('US594918104') # => 5
340
377
 
341
378
  # instance level
342
379
  isin = SecID::ISIN.new('US5949181045')
343
380
  isin.full_id # => 'US5949181045'
344
381
  isin.country_code # => 'US'
345
382
  isin.nsin # => '594918104'
346
- isin.check_digit # => 5
383
+ isin.checksum # => 5
347
384
  isin.valid? # => true
348
385
  isin.restore # => 'US5949181045'
349
386
  isin.restore! # => #<SecID::ISIN> (mutates instance)
350
- isin.calculate_check_digit # => 5
387
+ isin.calculate_checksum # => 5
351
388
  isin.to_pretty_s # => 'US 594918104 5'
352
389
  isin.to_cusip # => #<SecID::CUSIP>
353
390
  isin.nsin_type # => :cusip
@@ -381,18 +418,18 @@ SecID::ISIN.new('CH0012221716').to_valoren # => #<SecID::Valoren>
381
418
  SecID::CUSIP.valid?('594918104') # => true
382
419
  SecID::CUSIP.restore('59491810') # => '594918104'
383
420
  SecID::CUSIP.restore!('59491810') # => #<SecID::CUSIP>
384
- SecID::CUSIP.check_digit('59491810') # => 4
421
+ SecID::CUSIP.checksum('59491810') # => 4
385
422
 
386
423
  # instance level
387
424
  cusip = SecID::CUSIP.new('594918104')
388
425
  cusip.full_id # => '594918104'
389
426
  cusip.cusip6 # => '594918'
390
427
  cusip.issue # => '10'
391
- cusip.check_digit # => 4
428
+ cusip.checksum # => 4
392
429
  cusip.valid? # => true
393
430
  cusip.restore # => '594918104'
394
431
  cusip.restore! # => #<SecID::CUSIP> (mutates instance)
395
- cusip.calculate_check_digit # => 4
432
+ cusip.calculate_checksum # => 4
396
433
  cusip.to_pretty_s # => '594918 10 4'
397
434
  cusip.to_isin('US') # => #<SecID::ISIN>
398
435
  cusip.cins? # => false
@@ -407,7 +444,7 @@ cusip.cins? # => false
407
444
  SecID::CEI.valid?('A0BCDEFGH1') # => true
408
445
  SecID::CEI.restore('A0BCDEFGH') # => 'A0BCDEFGH1'
409
446
  SecID::CEI.restore!('A0BCDEFGH') # => #<SecID::CEI>
410
- SecID::CEI.check_digit('A0BCDEFGH') # => 1
447
+ SecID::CEI.checksum('A0BCDEFGH') # => 1
411
448
 
412
449
  # instance level
413
450
  cei = SecID::CEI.new('A0BCDEFGH1')
@@ -415,11 +452,11 @@ cei.full_id # => 'A0BCDEFGH1'
415
452
  cei.prefix # => 'A'
416
453
  cei.numeric # => '0'
417
454
  cei.entity_id # => 'BCDEFGH'
418
- cei.check_digit # => 1
455
+ cei.checksum # => 1
419
456
  cei.valid? # => true
420
457
  cei.restore # => 'A0BCDEFGH1'
421
458
  cei.restore! # => #<SecID::CEI> (mutates instance)
422
- cei.calculate_check_digit # => 1
459
+ cei.calculate_checksum # => 1
423
460
  ```
424
461
 
425
462
  ### SEDOL
@@ -431,16 +468,16 @@ cei.calculate_check_digit # => 1
431
468
  SecID::SEDOL.valid?('B0Z52W5') # => true
432
469
  SecID::SEDOL.restore('B0Z52W') # => 'B0Z52W5'
433
470
  SecID::SEDOL.restore!('B0Z52W') # => #<SecID::SEDOL>
434
- SecID::SEDOL.check_digit('B0Z52W') # => 5
471
+ SecID::SEDOL.checksum('B0Z52W') # => 5
435
472
 
436
473
  # instance level
437
474
  sedol = SecID::SEDOL.new('B0Z52W5')
438
475
  sedol.full_id # => 'B0Z52W5'
439
- sedol.check_digit # => 5
476
+ sedol.checksum # => 5
440
477
  sedol.valid? # => true
441
478
  sedol.restore # => 'B0Z52W5'
442
479
  sedol.restore! # => #<SecID::SEDOL> (mutates instance)
443
- sedol.calculate_check_digit # => 5
480
+ sedol.calculate_checksum # => 5
444
481
  sedol.to_isin # => #<SecID::ISIN> (GB ISIN by default)
445
482
  sedol.to_isin('IE') # => #<SecID::ISIN> (IE ISIN)
446
483
  ```
@@ -454,18 +491,18 @@ sedol.to_isin('IE') # => #<SecID::ISIN> (IE ISIN)
454
491
  SecID::FIGI.valid?('BBG000DMBXR2') # => true
455
492
  SecID::FIGI.restore('BBG000DMBXR') # => 'BBG000DMBXR2'
456
493
  SecID::FIGI.restore!('BBG000DMBXR') # => #<SecID::FIGI>
457
- SecID::FIGI.check_digit('BBG000DMBXR') # => 2
494
+ SecID::FIGI.checksum('BBG000DMBXR') # => 2
458
495
 
459
496
  # instance level
460
497
  figi = SecID::FIGI.new('BBG000DMBXR2')
461
498
  figi.full_id # => 'BBG000DMBXR2'
462
499
  figi.prefix # => 'BB'
463
500
  figi.random_part # => '000DMBXR'
464
- figi.check_digit # => 2
501
+ figi.checksum # => 2
465
502
  figi.valid? # => true
466
503
  figi.restore # => 'BBG000DMBXR2'
467
504
  figi.restore! # => #<SecID::FIGI> (mutates instance)
468
- figi.calculate_check_digit # => 2
505
+ figi.calculate_checksum # => 2
469
506
  figi.to_pretty_s # => 'BBG 000DMBXR 2'
470
507
  ```
471
508
 
@@ -478,7 +515,7 @@ figi.to_pretty_s # => 'BBG 000DMBXR 2'
478
515
  SecID::LEI.valid?('5493006MHB84DD0ZWV18') # => true
479
516
  SecID::LEI.restore('5493006MHB84DD0ZWV') # => '5493006MHB84DD0ZWV18'
480
517
  SecID::LEI.restore!('5493006MHB84DD0ZWV') # => #<SecID::LEI>
481
- SecID::LEI.check_digit('5493006MHB84DD0ZWV') # => 18
518
+ SecID::LEI.checksum('5493006MHB84DD0ZWV') # => 18
482
519
 
483
520
  # instance level
484
521
  lei = SecID::LEI.new('5493006MHB84DD0ZWV18')
@@ -486,11 +523,11 @@ lei.full_id # => '5493006MHB84DD0ZWV18'
486
523
  lei.lou_id # => '5493'
487
524
  lei.reserved # => '00'
488
525
  lei.entity_id # => '6MHB84DD0ZWV'
489
- lei.check_digit # => 18
526
+ lei.checksum # => 18
490
527
  lei.valid? # => true
491
528
  lei.restore # => '5493006MHB84DD0ZWV18'
492
529
  lei.restore! # => #<SecID::LEI> (mutates instance)
493
- lei.calculate_check_digit # => 18
530
+ lei.calculate_checksum # => 18
494
531
  lei.to_pretty_s # => '5493 006M HB84 DD0Z WV18'
495
532
  ```
496
533
 
@@ -503,7 +540,7 @@ lei.to_pretty_s # => '5493 006M HB84 DD0Z WV18'
503
540
  SecID::IBAN.valid?('DE89370400440532013000') # => true
504
541
  SecID::IBAN.restore('DE370400440532013000') # => 'DE89370400440532013000'
505
542
  SecID::IBAN.restore!('DE370400440532013000') # => #<SecID::IBAN>
506
- SecID::IBAN.check_digit('DE370400440532013000') # => 89
543
+ SecID::IBAN.checksum('DE370400440532013000') # => 89
507
544
 
508
545
  # instance level
509
546
  iban = SecID::IBAN.new('DE89370400440532013000')
@@ -512,11 +549,11 @@ iban.country_code # => 'DE'
512
549
  iban.bban # => '370400440532013000'
513
550
  iban.bank_code # => '37040044'
514
551
  iban.account_number # => '0532013000'
515
- iban.check_digit # => 89
552
+ iban.checksum # => 89
516
553
  iban.valid? # => true
517
554
  iban.restore # => 'DE89370400440532013000'
518
555
  iban.restore! # => #<SecID::IBAN> (mutates instance)
519
- iban.calculate_check_digit # => 89
556
+ iban.calculate_checksum # => 89
520
557
  iban.known_country? # => true
521
558
  iban.to_pretty_s # => 'DE89 3704 0044 0532 0130 00'
522
559
  ```
@@ -731,28 +768,54 @@ BIC validation confirms structure and a real country code only. It does **not**
731
768
  SecID::DTI.valid?('X9J9K872S') # => true
732
769
  SecID::DTI.restore('X9J9K872') # => 'X9J9K872S'
733
770
  SecID::DTI.restore!('X9J9K872') # => #<SecID::DTI>
734
- SecID::DTI.check_digit('X9J9K872') # => 'S'
771
+ SecID::DTI.checksum('X9J9K872') # => 'S'
735
772
 
736
773
  # instance level
737
774
  dti = SecID::DTI.new('X9J9K872S')
738
775
  dti.full_id # => 'X9J9K872S'
739
776
  dti.identifier # => 'X9J9K872'
740
- dti.check_digit # => 'S'
777
+ dti.checksum # => 'S'
741
778
  dti.valid? # => true
742
779
  dti.restore # => 'X9J9K872S'
743
780
  dti.restore! # => #<SecID::DTI> (mutates instance)
744
- dti.calculate_check_digit # => 'S'
781
+ dti.calculate_checksum # => 'S'
745
782
  ```
746
783
 
747
- DTI accepts exactly 9 characters: an 8-character base (first character never `0`) plus 1 check character, both drawn from a 30-symbol alphabet — digits `0`-`9` and consonants (vowels and `Y` never appear). Unlike every other check-digit type in this gem, `check_digit` and `calculate_check_digit` return a `String`, not an `Integer`. The check character is computed fully offline via ISO 7064 hybrid MOD 31,30 — no registry lookup or paywalled ISO 24165-1 spec required.
784
+ DTI accepts exactly 9 characters: an 8-character base (first character never `0`) plus 1 check character, both drawn from a 30-symbol alphabet — digits `0`-`9` and consonants (vowels and `Y` never appear). Unlike most checksum types in this gem, `checksum` and `calculate_checksum` return a `String`, not an `Integer` (as does UPI). The check character is computed fully offline via ISO 7064 hybrid MOD 31,30 — no registry lookup or paywalled ISO 24165-1 spec required.
748
785
 
749
- > **Grandfathered code:** Bitcoin's registered code (`4H95J0R2X`) predates the algorithm and fails the MOD 31,30 computation (which yields `4H95J0R2T`). A frozen exception map honors the registry's assignment across `valid?`, `restore`, and `check_digit` alike:
786
+ > **Grandfathered code:** Bitcoin's registered code (`4H95J0R2X`) predates the algorithm and fails the MOD 31,30 computation (which yields `4H95J0R2T`). A frozen exception map honors the registry's assignment across `valid?`, `restore`, and `checksum` alike:
750
787
  >
751
788
  > ```ruby
752
789
  > SecID::DTI.valid?('4H95J0R2X') # => true (registered code, via the exception map)
753
790
  > SecID::DTI.valid?('4H95J0R2T') # => false (algorithmic form is not the registered one)
754
791
  > ```
755
792
 
793
+ ### UPI
794
+
795
+ > [Unique Product Identifier](https://www.anna-dsb.com) - a 12-character code identifying OTC derivative products per ISO 4914, issued by the ANNA Derivatives Service Bureau and reported under CFTC, EMIR, and other global OTC-derivatives mandates.
796
+
797
+ ```ruby
798
+ # class level
799
+ SecID::UPI.valid?('QZRBG6ZTKS42') # => true
800
+ SecID::UPI.restore('QZRBG6ZTKS4') # => 'QZRBG6ZTKS42'
801
+ SecID::UPI.restore!('QZRBG6ZTKS4') # => #<SecID::UPI>
802
+ SecID::UPI.checksum('QZRBG6ZTKS4') # => '2'
803
+
804
+ # instance level
805
+ upi = SecID::UPI.new('QZRBG6ZTKS42')
806
+ upi.full_id # => 'QZRBG6ZTKS42'
807
+ upi.identifier # => 'QZRBG6ZTKS4'
808
+ upi.checksum # => '2'
809
+ upi.valid? # => true
810
+ upi.restore # => 'QZRBG6ZTKS42'
811
+ upi.restore! # => #<SecID::UPI> (mutates instance)
812
+ upi.calculate_checksum # => '2'
813
+ ```
814
+
815
+ UPI accepts exactly 12 characters: a fixed `QZ` prefix, a 9-character body, and 1 check character, all drawn from the same 30-symbol alphabet as DTI — digits `0`-`9` and consonants (vowels and `Y` never appear). Like DTI, `checksum` and `calculate_checksum` return a `String`, not an `Integer`. The check character is computed fully offline via ISO 7064 hybrid MOD 31,30 over the 11 preceding characters — no DSB registry lookup or paywalled ISO 4914 spec required.
816
+
817
+ > **Coexistence with ISIN:** a UPI shares the 12-character length bucket with ISIN. A UPI whose digit check character also satisfies ISIN's Luhn detects as both (`SecID.detect('QZXKR05S3DL1') # => [:isin, :upi]`), with ISIN ranked first; `SecID.parse(..., on_ambiguous: :raise)` surfaces the collision. UPI validation itself is fully offline and existence is **not** verified — that requires the licensed DSB registry.
818
+
756
819
  ## ActiveModel / Rails Validator
757
820
 
758
821
  SecID ships an opt-in [ActiveModel](https://api.rubyonrails.org/classes/ActiveModel/Validations.html) validator, registered as `sec_id`, for declarative validation of any supported identifier type. It adds **no runtime dependency** — `require 'sec_id'` loads none of it, and ActiveModel is a development/test dependency only.
@@ -803,7 +866,7 @@ On failure the validator adds one error under the `:sec_id` key with a type-awar
803
866
 
804
867
  ```ruby
805
868
  validates :isin, sec_id: { type: :isin, details: true }
806
- # a bad check digit reports e.g. "Check digit '4' is invalid, expected '5'"
869
+ # a bad checksum reports e.g. "Checksum '4' is invalid, expected '5'"
807
870
  ```
808
871
 
809
872
  Standard `EachValidator` options — `allow_nil`, `allow_blank`, `if`, `unless`, `on` — work as usual. Tested against Rails 7.2, 8.0, and 8.1.