gov_codes 0.1.0 → 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 +15 -1
- data/.tool-versions +1 -1
- data/CHANGELOG.md +57 -2
- data/README.md +223 -22
- data/Rakefile +7 -1
- data/checksums/gov_codes-0.1.0.gem.sha512 +1 -0
- data/checksums/gov_codes-0.1.1.gem.sha512 +1 -0
- data/lib/gov_codes/afsc/enlisted.rb +134 -24
- data/lib/gov_codes/afsc/officer.rb +141 -25
- 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 +207 -0
- data/lib/gov_codes/afsc.rb +35 -3
- 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 +49 -36
- data/lib/gov_codes/version.rb +1 -1
- metadata +36 -4
- data/lib/gov_codes/afsc/enlisted.yml +0 -725
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
require "strscan"
|
|
2
|
-
|
|
3
|
-
require_relative "../data_loader"
|
|
2
|
+
require_relative "releases"
|
|
4
3
|
|
|
5
4
|
module GovCodes
|
|
6
5
|
module AFSC
|
|
6
|
+
# Officer AFSCs resolved against the versioned DAFOCD release index. Mirrors
|
|
7
|
+
# Enlisted: +find(code, as_of:)+ resolves the X-form specialty (e.g. :11BX)
|
|
8
|
+
# or a literal bare code (e.g. :10C0) in the release in effect on +as_of+,
|
|
9
|
+
# defaulting to today.
|
|
7
10
|
module Officer
|
|
8
11
|
class Parser
|
|
9
12
|
def initialize(code)
|
|
@@ -17,7 +20,8 @@ module GovCodes
|
|
|
17
20
|
career_group: nil,
|
|
18
21
|
functional_area: nil,
|
|
19
22
|
qualification_level: nil,
|
|
20
|
-
shredout: nil
|
|
23
|
+
shredout: nil,
|
|
24
|
+
specific_afsc: nil
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
# Scan for prefix (optional)
|
|
@@ -26,20 +30,24 @@ module GovCodes
|
|
|
26
30
|
# Scan for career group (two digits)
|
|
27
31
|
career_group = scanner.scan(/\d{2}/)
|
|
28
32
|
return result unless career_group
|
|
29
|
-
result[:career_group] = career_group
|
|
33
|
+
result[:career_group] = career_group.to_sym
|
|
30
34
|
|
|
31
35
|
# Scan for functional area (uppercase letter)
|
|
32
36
|
functional_area = scanner.scan(/[A-Z]/)
|
|
33
37
|
return result unless functional_area
|
|
34
|
-
result[:functional_area] = functional_area
|
|
38
|
+
result[:functional_area] = functional_area.to_sym
|
|
35
39
|
|
|
36
|
-
# Scan for qualification level (digit 0-4)
|
|
37
|
-
qualification_level = scanner.scan(/[0-
|
|
40
|
+
# Scan for qualification level (digit 0-4 or letter X-Z)
|
|
41
|
+
qualification_level = scanner.scan(/[0-4A-Z]/)
|
|
38
42
|
return result unless qualification_level
|
|
39
|
-
result[:qualification_level] = qualification_level
|
|
43
|
+
result[:qualification_level] = qualification_level.to_sym
|
|
44
|
+
|
|
45
|
+
# Build specific AFSC
|
|
46
|
+
result[:specific_afsc] = :"#{result[:career_group]}#{result[:functional_area]}#{result[:qualification_level]}"
|
|
40
47
|
|
|
41
48
|
# Scan for shredout (optional)
|
|
42
|
-
|
|
49
|
+
shredout = scanner.scan(/[A-Z]/)
|
|
50
|
+
result[:shredout] = shredout&.to_sym
|
|
43
51
|
|
|
44
52
|
# Check if we've reached the end of the string
|
|
45
53
|
return result unless scanner.eos?
|
|
@@ -48,36 +56,144 @@ module GovCodes
|
|
|
48
56
|
end
|
|
49
57
|
end
|
|
50
58
|
|
|
51
|
-
|
|
52
|
-
|
|
59
|
+
CODES = {}
|
|
60
|
+
private_constant :CODES
|
|
53
61
|
|
|
54
62
|
Code = Data.define(
|
|
55
63
|
:prefix,
|
|
56
64
|
:career_group,
|
|
57
65
|
:functional_area,
|
|
58
66
|
:qualification_level,
|
|
67
|
+
:qualification_level_number,
|
|
68
|
+
:qualification_level_name,
|
|
59
69
|
:shredout,
|
|
60
|
-
:
|
|
70
|
+
:shredout_name,
|
|
71
|
+
:specialty,
|
|
72
|
+
:specialty_name,
|
|
73
|
+
:specific_afsc,
|
|
74
|
+
:name,
|
|
75
|
+
:acronym,
|
|
76
|
+
:effective_date
|
|
61
77
|
)
|
|
62
78
|
|
|
63
|
-
|
|
79
|
+
# Resolve an officer AFSC against the DAFOCD release in effect on +as_of+
|
|
80
|
+
# (default: today). Returns nil when the code does not parse, when neither
|
|
81
|
+
# the X-form specialty nor a literal bare code resolves in the release, or
|
|
82
|
+
# when +as_of+ precedes the earliest shipped release (or no release has
|
|
83
|
+
# taken effect yet).
|
|
84
|
+
def self.find(code, as_of: nil)
|
|
64
85
|
code = code.to_s
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
86
|
+
# Key the memo on the RESOLVED release date so equivalent as_of values
|
|
87
|
+
# (nil, the Date, its string form, any date in the same release window)
|
|
88
|
+
# share one slot instead of growing unbounded for time-series callers.
|
|
89
|
+
effective_date = Releases.effective_date_for(as_of: as_of, publication: Releases::OFFICER_PUBLICATION)
|
|
90
|
+
CODES[[code, effective_date]] ||= begin
|
|
91
|
+
result = Parser.new(code).parse
|
|
92
|
+
|
|
93
|
+
# Return nil if parsing failed or required fields are missing
|
|
94
|
+
return nil if result.reject { |_, v| v.nil? }.empty? ||
|
|
95
|
+
result[:career_group].nil? ||
|
|
96
|
+
result[:functional_area].nil? ||
|
|
97
|
+
result[:qualification_level].nil? ||
|
|
98
|
+
result[:specific_afsc].nil?
|
|
99
|
+
|
|
100
|
+
index = Releases.officer_index(as_of: as_of)
|
|
101
|
+
|
|
102
|
+
# Prefer the X-form specialty key (11B3 -> :11BX). Fall back to the
|
|
103
|
+
# literal 4-char code for bare specialties keyed that way (:10C0, :62S0,
|
|
104
|
+
# :63G0, :63S0). By design these bare-code specialties resolve ONLY via
|
|
105
|
+
# their literal code -- there is no X-form ladder for them, so
|
|
106
|
+
# find("10CX")/find("62SX")/find("63GX")/find("63SX") return nil while
|
|
107
|
+
# find("10C0")/find("62S0")/find("63G0")/find("63S0") resolve.
|
|
108
|
+
specialty = :"#{result[:career_group]}#{result[:functional_area]}X"
|
|
109
|
+
if !index.key?(specialty) && index.key?(result[:specific_afsc])
|
|
110
|
+
specialty = result[:specific_afsc]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
entry = index[specialty]
|
|
114
|
+
return nil unless entry
|
|
115
|
+
|
|
116
|
+
result[:specialty] = specialty
|
|
117
|
+
result[:specialty_name] = entry[:name]
|
|
118
|
+
|
|
119
|
+
# Qualification-level title: numeric level (1-4) maps to the directory's
|
|
120
|
+
# per-specialty title; a letter (X/Y/Z) or an absent level leaves it nil.
|
|
121
|
+
result[:qualification_level_number] = nil
|
|
122
|
+
result[:qualification_level_name] = nil
|
|
123
|
+
qual = result[:qualification_level].to_s
|
|
124
|
+
if qual.match?(/\d/)
|
|
125
|
+
number = Integer(qual)
|
|
126
|
+
result[:qualification_level_number] = number
|
|
127
|
+
result[:qualification_level_name] = entry.dig(:qual_levels, number, :title)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Shredout meaning, only when the directory documents this shredout.
|
|
131
|
+
shredout = result[:shredout]
|
|
132
|
+
shredout_name = shredout && entry.dig(:shredouts, shredout)
|
|
133
|
+
result[:shredout_name] = shredout_name
|
|
134
|
+
|
|
135
|
+
name = shredout_name || entry[:name]
|
|
136
|
+
return nil if name.nil?
|
|
137
|
+
result[:name] = name
|
|
138
|
+
|
|
139
|
+
# Acronym: a documented shredout acronym wins for a shredded code;
|
|
140
|
+
# otherwise the specialty acronym (which a consumer overlay may set).
|
|
141
|
+
shredout_acronym = shredout && entry.dig(:shredout_acronyms, shredout)
|
|
142
|
+
result[:acronym] = shredout_acronym || entry[:acronym]
|
|
143
|
+
result[:effective_date] = effective_date
|
|
144
|
+
|
|
145
|
+
Code.new(**result)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
69
148
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
149
|
+
# Resolve the officer specialty/shredout whose acronym matches +acronym+
|
|
150
|
+
# (case-insensitive) in the release in effect on +as_of+. Searches both
|
|
151
|
+
# specialty acronyms and shredout acronyms; a shredout match returns the
|
|
152
|
+
# concrete shredded code (so "TACPO" resolves find("19ZXB")). First match
|
|
153
|
+
# wins: entries are scanned in index order and, within an entry, the
|
|
154
|
+
# specialty acronym is tried before its shredout acronyms. Returns nil when
|
|
155
|
+
# no acronym in the resolved release matches (no leakage across dates).
|
|
156
|
+
def self.find_by_acronym(acronym, as_of: nil)
|
|
157
|
+
acronym = acronym.to_s.upcase
|
|
158
|
+
return nil if acronym.empty?
|
|
159
|
+
|
|
160
|
+
index = Releases.officer_index(as_of: as_of)
|
|
161
|
+
index.each do |specialty, entry|
|
|
162
|
+
return find(specialty.to_s, as_of: as_of) if entry[:acronym].to_s.upcase == acronym
|
|
163
|
+
|
|
164
|
+
(entry[:shredout_acronyms] || {}).each do |shredout, acr|
|
|
165
|
+
return find("#{specialty}#{shredout}", as_of: as_of) if acr.to_s.upcase == acronym
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
nil
|
|
169
|
+
end
|
|
73
170
|
|
|
74
|
-
|
|
75
|
-
|
|
171
|
+
# Walk the resolved index emitting each specialty code and its
|
|
172
|
+
# specialty+shredout combinations, returning the Codes matching +prefix+.
|
|
173
|
+
def self.search(prefix, as_of: nil)
|
|
174
|
+
prefix = prefix.to_s.upcase
|
|
175
|
+
index = Releases.officer_index(as_of: as_of)
|
|
176
|
+
|
|
177
|
+
codes = []
|
|
178
|
+
index.each do |specialty, entry|
|
|
179
|
+
specialty_code = specialty.to_s
|
|
180
|
+
codes << specialty_code
|
|
181
|
+
(entry[:shredouts] || {}).each_key do |shredout|
|
|
182
|
+
codes << "#{specialty_code}#{shredout}"
|
|
183
|
+
end
|
|
184
|
+
end
|
|
76
185
|
|
|
77
|
-
|
|
78
|
-
|
|
186
|
+
codes.select { |code| code.start_with?(prefix) }
|
|
187
|
+
.map { |code| find(code, as_of: as_of) }
|
|
188
|
+
.compact
|
|
189
|
+
end
|
|
79
190
|
|
|
80
|
-
|
|
191
|
+
# Clears the memoized lookups and resets the versioned release loader. The
|
|
192
|
+
# +lookup+ keyword is accepted for interface parity with Enlisted/RI; the
|
|
193
|
+
# versioned index is resolved from the load path at lookup time.
|
|
194
|
+
def self.reset_data(lookup: $LOAD_PATH)
|
|
195
|
+
Releases.reset!
|
|
196
|
+
CODES.clear
|
|
81
197
|
end
|
|
82
198
|
end
|
|
83
199
|
end
|