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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2ce3f9d548d4530dae973ee4ee4a7cf8e18898aaa36396423efe5ece7dd091f4
|
|
4
|
+
data.tar.gz: 5b7950a09cfc4afd59358b230fd659a9f26a1e58e50577ed16267f4763142a7f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 [](https://rubygems.org/gems/sec_id) [](https://github.com/svyatov/sec_id/actions/workflows/main.yml) [](https://codecov.io/gh/svyatov/sec_id) [](https://rubydoc.info/gems/sec_id) [](https://www.ruby-lang.org) [](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
|
|
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', '~>
|
|
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',
|
|
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,
|
|
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
|
-
**
|
|
106
|
-
- `restore` / `.restore` - returns the full identifier string with correct
|
|
107
|
-
- `restore!` / `.restore!` - restores
|
|
108
|
-
- `
|
|
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.
|
|
131
|
+
SecID::ISIN.has_checksum? # => true
|
|
130
132
|
|
|
131
133
|
# Filter with standard Ruby
|
|
132
|
-
SecID.identifiers.select(&:
|
|
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:
|
|
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] # => :
|
|
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 # => ["
|
|
219
|
-
isin.errors.details # => [{ error: :
|
|
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
|
-
- `:
|
|
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::
|
|
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
|
|
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.
|
|
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.
|
|
383
|
+
isin.checksum # => 5
|
|
347
384
|
isin.valid? # => true
|
|
348
385
|
isin.restore # => 'US5949181045'
|
|
349
386
|
isin.restore! # => #<SecID::ISIN> (mutates instance)
|
|
350
|
-
isin.
|
|
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.
|
|
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.
|
|
428
|
+
cusip.checksum # => 4
|
|
392
429
|
cusip.valid? # => true
|
|
393
430
|
cusip.restore # => '594918104'
|
|
394
431
|
cusip.restore! # => #<SecID::CUSIP> (mutates instance)
|
|
395
|
-
cusip.
|
|
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.
|
|
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.
|
|
455
|
+
cei.checksum # => 1
|
|
419
456
|
cei.valid? # => true
|
|
420
457
|
cei.restore # => 'A0BCDEFGH1'
|
|
421
458
|
cei.restore! # => #<SecID::CEI> (mutates instance)
|
|
422
|
-
cei.
|
|
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.
|
|
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.
|
|
476
|
+
sedol.checksum # => 5
|
|
440
477
|
sedol.valid? # => true
|
|
441
478
|
sedol.restore # => 'B0Z52W5'
|
|
442
479
|
sedol.restore! # => #<SecID::SEDOL> (mutates instance)
|
|
443
|
-
sedol.
|
|
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.
|
|
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.
|
|
501
|
+
figi.checksum # => 2
|
|
465
502
|
figi.valid? # => true
|
|
466
503
|
figi.restore # => 'BBG000DMBXR2'
|
|
467
504
|
figi.restore! # => #<SecID::FIGI> (mutates instance)
|
|
468
|
-
figi.
|
|
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.
|
|
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.
|
|
526
|
+
lei.checksum # => 18
|
|
490
527
|
lei.valid? # => true
|
|
491
528
|
lei.restore # => '5493006MHB84DD0ZWV18'
|
|
492
529
|
lei.restore! # => #<SecID::LEI> (mutates instance)
|
|
493
|
-
lei.
|
|
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.
|
|
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.
|
|
552
|
+
iban.checksum # => 89
|
|
516
553
|
iban.valid? # => true
|
|
517
554
|
iban.restore # => 'DE89370400440532013000'
|
|
518
555
|
iban.restore! # => #<SecID::IBAN> (mutates instance)
|
|
519
|
-
iban.
|
|
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.
|
|
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.
|
|
777
|
+
dti.checksum # => 'S'
|
|
741
778
|
dti.valid? # => true
|
|
742
779
|
dti.restore # => 'X9J9K872S'
|
|
743
780
|
dti.restore! # => #<SecID::DTI> (mutates instance)
|
|
744
|
-
dti.
|
|
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
|
|
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 `
|
|
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
|
|
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.
|