gov_codes 0.1.1 → 0.1.2
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/.simplecov +13 -1
- data/.tool-versions +1 -1
- data/CHANGELOG.md +31 -26
- data/README.md +180 -25
- data/Rakefile +7 -2
- data/checksums/gov_codes-0.1.1.gem.sha512 +1 -0
- data/lib/gov_codes/afsc/enlisted.rb +111 -80
- data/lib/gov_codes/afsc/officer.rb +121 -65
- data/lib/gov_codes/afsc/releases/dafecd/2025-10-31/enlisted.yml +2726 -0
- data/lib/gov_codes/afsc/releases/dafecd/2025-10-31/ri.yml +369 -0
- data/lib/gov_codes/afsc/releases/dafocd/2025-10-31/officer.yml +2393 -0
- data/lib/gov_codes/afsc/releases/dafocd/2025-10-31/ri.yml +193 -0
- data/lib/gov_codes/afsc/releases.rb +219 -0
- data/lib/gov_codes/afsc/releases.yml +11 -0
- data/lib/gov_codes/afsc/ri.rb +161 -126
- data/lib/gov_codes/afsc.rb +29 -8
- data/lib/gov_codes/dafecd/index_builder.rb +416 -0
- data/lib/gov_codes/dafecd/patterns.rb +50 -0
- data/lib/gov_codes/dafecd/publication.rb +252 -0
- data/lib/gov_codes/dafecd/record_splitter.rb +77 -0
- data/lib/gov_codes/dafecd/ri_sdi/change_date.rb +63 -0
- data/lib/gov_codes/dafecd/ri_sdi/config.rb +137 -0
- data/lib/gov_codes/dafecd/ri_sdi/index_builder.rb +265 -0
- data/lib/gov_codes/dafecd/ri_sdi/ri_list_parser.rb +125 -0
- data/lib/gov_codes/dafecd/ri_sdi/sdi_card_parser.rb +151 -0
- data/lib/gov_codes/dafecd/ri_sdi/sdi_section_splitter.rb +77 -0
- data/lib/gov_codes/dafecd/ri_sdi/section_slicer.rb +45 -0
- data/lib/gov_codes/dafecd/ri_sdi/title.rb +76 -0
- data/lib/gov_codes/dafecd/ri_sdi/title_overrides/dafecd.yml +124 -0
- data/lib/gov_codes/dafecd/ri_sdi/title_overrides/dafocd.yml +78 -0
- data/lib/gov_codes/dafecd/shredout_acronyms.rb +47 -0
- data/lib/gov_codes/dafecd/shredout_degluer.rb +73 -0
- data/lib/gov_codes/dafecd/shredout_overrides/dafecd.yml +27 -0
- data/lib/gov_codes/dafecd/shredout_overrides/dafocd.yml +27 -0
- data/lib/gov_codes/dafecd/shredout_parser.rb +75 -0
- data/lib/gov_codes/dafecd/specialty_parser.rb +200 -0
- data/lib/gov_codes/dafecd/text.rb +48 -0
- data/lib/gov_codes/dafecd/title_degluer.rb +67 -0
- data/lib/gov_codes/dafecd/title_overrides/dafocd.yml +141 -0
- data/lib/gov_codes/dafecd/title_overrides.yml +142 -0
- data/lib/gov_codes/data_loader.rb +19 -0
- data/lib/gov_codes/version.rb +1 -1
- metadata +34 -6
- data/lib/gov_codes/afsc/enlisted.yml +0 -532
- data/lib/gov_codes/afsc/officer.yml +0 -1072
- data/lib/gov_codes/afsc/ri.yml +0 -237
data/lib/gov_codes/afsc/ri.rb
CHANGED
|
@@ -1,64 +1,100 @@
|
|
|
1
1
|
require "strscan"
|
|
2
|
-
|
|
3
|
-
require_relative "../data_loader"
|
|
2
|
+
require_relative "releases"
|
|
4
3
|
|
|
5
4
|
module GovCodes
|
|
6
5
|
module AFSC
|
|
7
|
-
# Reporting Identifiers (RI) and Special Duty Identifiers (SDI)
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
# - Identifier: 3 digits (e.g., "200", "400")
|
|
11
|
-
# - Optional suffix: letter (e.g., "A", "B")
|
|
6
|
+
# Reporting Identifiers (RI) and Special Duty Identifiers (SDI), resolved
|
|
7
|
+
# against the versioned classification-directory release indexes. RI/SDI
|
|
8
|
+
# codes live in BOTH directories under two different code grammars:
|
|
12
9
|
#
|
|
13
|
-
#
|
|
10
|
+
# Enlisted (DAFECD), 5-char: \d[A-Z]\d{3}[A-Z]? e.g. 9Z200, 8R300A
|
|
11
|
+
# Officer (DAFOCD), 4-char: \d{2}[A-Z]\d[A-Z]? e.g. 90G0, 92T1
|
|
12
|
+
#
|
|
13
|
+
# A single +find+ entry point dispatches by shape to the matching
|
|
14
|
+
# publication's ri.yml index (Releases.ri_index). Entries share the shape of
|
|
15
|
+
# enlisted/officer specialty records (:name, :acronym, :shredouts,
|
|
16
|
+
# :shredout_acronyms), so shredout suffixes and acronyms resolve exactly as
|
|
17
|
+
# they do for Enlisted/Officer.
|
|
14
18
|
module RI
|
|
15
19
|
class Parser
|
|
16
20
|
def initialize(code)
|
|
17
|
-
@code = code
|
|
21
|
+
@code = code.to_s
|
|
18
22
|
end
|
|
19
23
|
|
|
24
|
+
# Try the 5-char enlisted shape first, then the 4-char officer shape.
|
|
25
|
+
# Both shapes are mutually exclusive (an enlisted code's 2nd char is a
|
|
26
|
+
# letter; an officer code's 2nd char is a digit), so at most one matches.
|
|
27
|
+
# The result carries the decomposed fields plus the :publication whose
|
|
28
|
+
# index should resolve the name; all nil when neither shape parses.
|
|
20
29
|
def parse
|
|
21
|
-
|
|
22
|
-
|
|
30
|
+
parse_enlisted || parse_officer || empty_result
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def empty_result
|
|
36
|
+
{
|
|
23
37
|
career_group: nil,
|
|
24
38
|
career_field: nil,
|
|
25
39
|
identifier: nil,
|
|
26
40
|
suffix: nil,
|
|
27
|
-
specific_ri: nil
|
|
41
|
+
specific_ri: nil,
|
|
42
|
+
publication: nil
|
|
28
43
|
}
|
|
44
|
+
end
|
|
29
45
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
return result unless career_field_letter
|
|
38
|
-
result[:career_field] = :"#{career_group}#{career_field_letter}"
|
|
39
|
-
|
|
40
|
-
# Scan for identifier (3 digits)
|
|
41
|
-
identifier = scanner.scan(/\d{3}/)
|
|
42
|
-
return result unless identifier
|
|
43
|
-
result[:identifier] = identifier.to_sym
|
|
44
|
-
|
|
45
|
-
# Build specific RI code
|
|
46
|
-
result[:specific_ri] = :"#{result[:career_field]}#{identifier}"
|
|
47
|
-
|
|
48
|
-
# Scan for optional suffix (letter)
|
|
46
|
+
# Enlisted shape: career_group (1 digit), career_field (digit+letter,
|
|
47
|
+
# e.g. "9Z"), identifier (3 digits), optional 1-letter suffix.
|
|
48
|
+
def parse_enlisted
|
|
49
|
+
scanner = StringScanner.new(@code)
|
|
50
|
+
career_group = scanner.scan(/\d/) or return nil
|
|
51
|
+
field_letter = scanner.scan(/[A-Z]/) or return nil
|
|
52
|
+
identifier = scanner.scan(/\d{3}/) or return nil
|
|
49
53
|
suffix = scanner.scan(/[A-Z]/)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
+
return nil unless scanner.eos?
|
|
55
|
+
|
|
56
|
+
career_field = :"#{career_group}#{field_letter}"
|
|
57
|
+
{
|
|
58
|
+
career_group: career_group.to_sym,
|
|
59
|
+
career_field: career_field,
|
|
60
|
+
identifier: identifier.to_sym,
|
|
61
|
+
suffix: suffix&.to_sym,
|
|
62
|
+
specific_ri: :"#{career_field}#{identifier}",
|
|
63
|
+
publication: Releases::ENLISTED_PUBLICATION
|
|
64
|
+
}
|
|
65
|
+
end
|
|
54
66
|
|
|
55
|
-
|
|
67
|
+
# Officer shape: same grammar as an officer AFSC (2-digit career group +
|
|
68
|
+
# functional-area letter + 1-digit qualification level). There is no
|
|
69
|
+
# natural 3-digit identifier here, so these codes are mapped onto the
|
|
70
|
+
# SAME Code struct with the closest-matching semantics:
|
|
71
|
+
# career_group the 2-digit prefix (e.g. :"90")
|
|
72
|
+
# career_field 2-digit + letter (e.g. :"90G")
|
|
73
|
+
# identifier the trailing digit (e.g. :"0") <- 1 digit, not 3;
|
|
74
|
+
# intentional, not a bug
|
|
75
|
+
# specific_ri the full 4-char code (e.g. :"90G0")
|
|
76
|
+
def parse_officer
|
|
77
|
+
scanner = StringScanner.new(@code)
|
|
78
|
+
career_group = scanner.scan(/\d{2}/) or return nil
|
|
79
|
+
field_letter = scanner.scan(/[A-Z]/) or return nil
|
|
80
|
+
identifier = scanner.scan(/\d/) or return nil
|
|
81
|
+
suffix = scanner.scan(/[A-Z]/)
|
|
82
|
+
return nil unless scanner.eos?
|
|
83
|
+
|
|
84
|
+
career_field = :"#{career_group}#{field_letter}"
|
|
85
|
+
{
|
|
86
|
+
career_group: career_group.to_sym,
|
|
87
|
+
career_field: career_field,
|
|
88
|
+
identifier: identifier.to_sym,
|
|
89
|
+
suffix: suffix&.to_sym,
|
|
90
|
+
specific_ri: :"#{career_field}#{identifier}",
|
|
91
|
+
publication: Releases::OFFICER_PUBLICATION
|
|
92
|
+
}
|
|
56
93
|
end
|
|
57
94
|
end
|
|
58
95
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
DATA = data
|
|
96
|
+
CODES = {}
|
|
97
|
+
private_constant :CODES
|
|
62
98
|
|
|
63
99
|
Code = Data.define(
|
|
64
100
|
:career_group,
|
|
@@ -66,107 +102,106 @@ module GovCodes
|
|
|
66
102
|
:identifier,
|
|
67
103
|
:suffix,
|
|
68
104
|
:specific_ri,
|
|
69
|
-
:name
|
|
105
|
+
:name,
|
|
106
|
+
:acronym,
|
|
107
|
+
:effective_date
|
|
70
108
|
)
|
|
71
109
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
110
|
+
# Resolve an RI/SDI code against the release in effect on +as_of+ (default:
|
|
111
|
+
# today). Dispatches by code shape to the enlisted (DAFECD) or officer
|
|
112
|
+
# (DAFOCD) ri.yml index. Returns nil when the code parses as neither shape,
|
|
113
|
+
# when the resolved index lacks the code, or when +as_of+ precedes the
|
|
114
|
+
# earliest shipped release for the relevant publication.
|
|
115
|
+
def self.find(code, as_of: nil)
|
|
116
|
+
code = code.to_s
|
|
117
|
+
parsed = Parser.new(code).parse
|
|
118
|
+
publication = parsed[:publication]
|
|
119
|
+
return nil unless publication
|
|
120
|
+
|
|
121
|
+
# Key the memo on the RESOLVED release date so equivalent as_of values
|
|
122
|
+
# (nil, the Date, its string form) share one slot.
|
|
123
|
+
effective_date = Releases.effective_date_for(as_of: as_of, publication: publication)
|
|
124
|
+
CODES[[code, effective_date]] ||= begin
|
|
125
|
+
index = Releases.ri_index(as_of: as_of, publication: publication)
|
|
126
|
+
entry = index[parsed[:specific_ri]]
|
|
127
|
+
return nil unless entry
|
|
128
|
+
|
|
129
|
+
suffix = parsed[:suffix]
|
|
130
|
+
# Shredout suffix meaning, only when the directory documents it.
|
|
131
|
+
shredout_name = suffix && entry.dig(:shredouts, suffix)
|
|
132
|
+
name = shredout_name || entry[:name]
|
|
133
|
+
return nil if name.nil?
|
|
134
|
+
|
|
135
|
+
# A documented shredout acronym wins for a shredded code; otherwise the
|
|
136
|
+
# entry's own acronym (which a consumer overlay may set).
|
|
137
|
+
shredout_acronym = suffix && entry.dig(:shredout_acronyms, suffix)
|
|
138
|
+
|
|
139
|
+
Code.new(
|
|
140
|
+
career_group: parsed[:career_group],
|
|
141
|
+
career_field: parsed[:career_field],
|
|
142
|
+
identifier: parsed[:identifier],
|
|
143
|
+
suffix: suffix,
|
|
144
|
+
specific_ri: parsed[:specific_ri],
|
|
145
|
+
name: name,
|
|
146
|
+
acronym: shredout_acronym || entry[:acronym],
|
|
147
|
+
effective_date: effective_date
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Resolve the RI/SDI code whose acronym matches +acronym+ (case-insensitive)
|
|
153
|
+
# in the release in effect on +as_of+. Scans the enlisted index first, then
|
|
154
|
+
# the officer index; within an entry the entry's own acronym is tried before
|
|
155
|
+
# its shredout acronyms (a shredout match returns the concrete shredded
|
|
156
|
+
# code). First match wins. Returns nil when no acronym in the resolved
|
|
157
|
+
# release(s) matches (no leakage across document dates).
|
|
158
|
+
def self.find_by_acronym(acronym, as_of: nil)
|
|
159
|
+
acronym = acronym.to_s.upcase
|
|
160
|
+
return nil if acronym.empty?
|
|
161
|
+
|
|
162
|
+
[Releases::ENLISTED_PUBLICATION, Releases::OFFICER_PUBLICATION].each do |publication|
|
|
163
|
+
index = Releases.ri_index(as_of: as_of, publication: publication)
|
|
164
|
+
index.each do |code, entry|
|
|
165
|
+
return find(code.to_s, as_of: as_of) if entry[:acronym].to_s.upcase == acronym
|
|
166
|
+
|
|
167
|
+
(entry[:shredout_acronyms] || {}).each do |suffix, acr|
|
|
168
|
+
return find("#{code}#{suffix}", as_of: as_of) if acr.to_s.upcase == acronym
|
|
105
169
|
end
|
|
106
170
|
end
|
|
107
171
|
end
|
|
108
|
-
|
|
109
|
-
name || "Unknown"
|
|
172
|
+
nil
|
|
110
173
|
end
|
|
111
174
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
# Return nil if parsing failed or required fields are missing
|
|
119
|
-
return nil if result.reject { |_, v| v.nil? }.empty? ||
|
|
120
|
-
result[:career_group].nil? ||
|
|
121
|
-
result[:career_field].nil? ||
|
|
122
|
-
result[:identifier].nil? ||
|
|
123
|
-
result[:specific_ri].nil?
|
|
124
|
-
|
|
125
|
-
# Find the name by recursively searching the codes hash
|
|
126
|
-
name = find_name_recursive(result)
|
|
127
|
-
|
|
128
|
-
# Return nil if name is "Unknown" (code not in data)
|
|
129
|
-
return nil if name == "Unknown"
|
|
130
|
-
|
|
131
|
-
# Add the name to the result
|
|
132
|
-
result[:name] = name
|
|
175
|
+
# Walk both publications' RI/SDI indexes for the release in effect on
|
|
176
|
+
# +as_of+, emitting each base code and its shredout-suffixed combinations,
|
|
177
|
+
# and return the Codes whose code starts with +prefix+.
|
|
178
|
+
def self.search(prefix, as_of: nil)
|
|
179
|
+
prefix = prefix.to_s.upcase
|
|
133
180
|
|
|
134
|
-
|
|
181
|
+
codes = []
|
|
182
|
+
[Releases::ENLISTED_PUBLICATION, Releases::OFFICER_PUBLICATION].each do |publication|
|
|
183
|
+
index = Releases.ri_index(as_of: as_of, publication: publication)
|
|
184
|
+
index.each do |code, entry|
|
|
185
|
+
code_str = code.to_s
|
|
186
|
+
codes << code_str
|
|
187
|
+
(entry[:shredouts] || {}).each_key do |suffix|
|
|
188
|
+
codes << "#{code_str}#{suffix}"
|
|
189
|
+
end
|
|
190
|
+
end
|
|
135
191
|
end
|
|
192
|
+
|
|
193
|
+
codes.select { |code| code.start_with?(prefix) }
|
|
194
|
+
.map { |code| find(code, as_of: as_of) }
|
|
195
|
+
.compact
|
|
136
196
|
end
|
|
137
197
|
|
|
198
|
+
# Clears the memoized lookups and resets the versioned release loader. The
|
|
199
|
+
# +lookup+ keyword is accepted for interface parity with Enlisted/Officer;
|
|
200
|
+
# the versioned index is resolved from the load path at lookup time.
|
|
138
201
|
def self.reset_data(lookup: $LOAD_PATH)
|
|
139
|
-
|
|
140
|
-
const_set(:DATA, data(lookup:))
|
|
202
|
+
Releases.reset!
|
|
141
203
|
CODES.clear
|
|
142
204
|
end
|
|
143
|
-
|
|
144
|
-
def self.search(prefix)
|
|
145
|
-
results = []
|
|
146
|
-
prefix = prefix.to_s.upcase
|
|
147
|
-
collect_codes_recursive(DATA, "", prefix, results)
|
|
148
|
-
results.map { |code| find(code) }.compact
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
def self.collect_codes_recursive(data, current_code, prefix, results)
|
|
152
|
-
return unless data.is_a?(Hash)
|
|
153
|
-
|
|
154
|
-
data.each do |key, value|
|
|
155
|
-
code = "#{current_code}#{key}"
|
|
156
|
-
|
|
157
|
-
if value.is_a?(Hash) && value[:name]
|
|
158
|
-
# This is a node with a name and possibly subcategories
|
|
159
|
-
results << code if code.start_with?(prefix)
|
|
160
|
-
collect_codes_recursive(value[:subcategories], code, prefix, results) if value[:subcategories]
|
|
161
|
-
elsif value.is_a?(String)
|
|
162
|
-
# This is a leaf node (simple string value)
|
|
163
|
-
results << code if code.start_with?(prefix)
|
|
164
|
-
elsif value.is_a?(Hash)
|
|
165
|
-
# Nested subcategories without a name at this level
|
|
166
|
-
collect_codes_recursive(value, current_code, prefix, results)
|
|
167
|
-
end
|
|
168
|
-
end
|
|
169
|
-
end
|
|
170
205
|
end
|
|
171
206
|
end
|
|
172
207
|
end
|
data/lib/gov_codes/afsc.rb
CHANGED
|
@@ -4,17 +4,38 @@ require_relative "afsc/ri"
|
|
|
4
4
|
|
|
5
5
|
module GovCodes
|
|
6
6
|
module AFSC
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
# Resolve a code as of the classification-directory release in effect on
|
|
8
|
+
# +as_of+ (default: today). +as_of+ applies to every tier: the versioned
|
|
9
|
+
# enlisted (DAFECD) and officer (DAFOCD) AFSC lookups and the versioned
|
|
10
|
+
# RI/SDI lookup (which itself dispatches by code shape to the DAFECD or
|
|
11
|
+
# DAFOCD RI index), each resolved against its own publication.
|
|
12
|
+
def self.find(code, as_of: nil)
|
|
13
|
+
AFSC::Enlisted.find(code, as_of: as_of) ||
|
|
14
|
+
AFSC::Officer.find(code, as_of: as_of) ||
|
|
15
|
+
AFSC::RI.find(code, as_of: as_of)
|
|
11
16
|
end
|
|
12
17
|
|
|
13
|
-
|
|
18
|
+
# Resolve a code by its acronym (case-insensitive) as of the release in
|
|
19
|
+
# effect on +as_of+ (default: today). Searches the enlisted tier
|
|
20
|
+
# (source-verified acronyms + that release's overlay), then the officer
|
|
21
|
+
# tier (specialty and shredout acronyms + that release's overlay), then the
|
|
22
|
+
# versioned RI/SDI tier (enlisted then officer RI index for that release).
|
|
23
|
+
# Returns a single Code or nil.
|
|
24
|
+
#
|
|
25
|
+
# Acronyms are expected to be unique. If two codes carry the same acronym,
|
|
26
|
+
# the first match wins in a defined order: enlisted before officer before RI,
|
|
27
|
+
# and within a tier by the data's key order.
|
|
28
|
+
def self.find_by_acronym(acronym, as_of: nil)
|
|
29
|
+
AFSC::Enlisted.find_by_acronym(acronym, as_of: as_of) ||
|
|
30
|
+
AFSC::Officer.find_by_acronym(acronym, as_of: as_of) ||
|
|
31
|
+
AFSC::RI.find_by_acronym(acronym, as_of: as_of)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.search(prefix, as_of: nil)
|
|
14
35
|
results = []
|
|
15
|
-
results.concat(Enlisted.search(prefix))
|
|
16
|
-
results.concat(Officer.search(prefix))
|
|
17
|
-
results.concat(RI.search(prefix))
|
|
36
|
+
results.concat(Enlisted.search(prefix, as_of: as_of))
|
|
37
|
+
results.concat(Officer.search(prefix, as_of: as_of))
|
|
38
|
+
results.concat(RI.search(prefix, as_of: as_of))
|
|
18
39
|
results
|
|
19
40
|
end
|
|
20
41
|
|