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
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# DAFOCD Reporting Identifiers (RI) + Special Duty Identifiers (SDI)
|
|
2
|
+
# Source: DAFOCD 31 Oct 25 v3.pdf
|
|
3
|
+
# Directory: Department of the Air Force Officer Classification Directory
|
|
4
|
+
# Effective date: 2025-10-31 (v3)
|
|
5
|
+
# Generated deterministically by bin/extract_afsc_from_pdf.rb.
|
|
6
|
+
# Do not edit by hand; re-run the extractor against the source PDF.
|
|
7
|
+
# Titles are de-glued via human-verified overrides (spacing/case only, gated
|
|
8
|
+
# against the verbatim source); a title without an override is kept verbatim.
|
|
9
|
+
---
|
|
10
|
+
:80C0:
|
|
11
|
+
:name: Commander, Cadet Squadron, USAF Academy
|
|
12
|
+
:changed_date: '2022-10-31'
|
|
13
|
+
:81C0:
|
|
14
|
+
:name: Instructor, Officer Training School
|
|
15
|
+
:changed_date: '2019-04-30'
|
|
16
|
+
:81D0:
|
|
17
|
+
:name: Air Force Reserve Officer Training Corps Detachment Commander and Professor
|
|
18
|
+
of Aerospace Studies
|
|
19
|
+
:changed_date: '2020-04-30'
|
|
20
|
+
:81L0:
|
|
21
|
+
:name: Education and Training Leader
|
|
22
|
+
:changed_date: '2020-04-30'
|
|
23
|
+
:81T0:
|
|
24
|
+
:name: Instructor
|
|
25
|
+
:82A0:
|
|
26
|
+
:name: Academic Program Manager
|
|
27
|
+
:83R0:
|
|
28
|
+
:name: Recruiting Service
|
|
29
|
+
:84H0:
|
|
30
|
+
:name: Historian
|
|
31
|
+
:changed_date: '2024-10-31'
|
|
32
|
+
:85G0:
|
|
33
|
+
:name: Honor Guard
|
|
34
|
+
:changed_date: '2025-10-31'
|
|
35
|
+
:86M0:
|
|
36
|
+
:name: Operations Management
|
|
37
|
+
:86P0:
|
|
38
|
+
:name: Command and Control
|
|
39
|
+
:87G0:
|
|
40
|
+
:name: Wing Inspector General
|
|
41
|
+
:changed_date: '2018-04-30'
|
|
42
|
+
:87I0:
|
|
43
|
+
:name: Director, Wing Inspections
|
|
44
|
+
:changed_date: '2012-10-31'
|
|
45
|
+
:87Q0:
|
|
46
|
+
:name: Director, Complaints Resolution
|
|
47
|
+
:changed_date: '2012-10-31'
|
|
48
|
+
:88A0:
|
|
49
|
+
:name: Aide-de-camp
|
|
50
|
+
:88B0:
|
|
51
|
+
:name: Protocol Officer
|
|
52
|
+
:changed_date: '2019-10-31'
|
|
53
|
+
:88C0:
|
|
54
|
+
:name: Sexual Assault Response Coordinator (SARC)
|
|
55
|
+
:acronym: SARC
|
|
56
|
+
:changed_date: '2020-04-30'
|
|
57
|
+
:88I0:
|
|
58
|
+
:name: Innovation Officer
|
|
59
|
+
:changed_date: '2022-10-31'
|
|
60
|
+
:88M0:
|
|
61
|
+
:name: United States Air Force Medical Pinnacle Position
|
|
62
|
+
:changed_date: '2024-04-30'
|
|
63
|
+
:88X0:
|
|
64
|
+
:name: Operational STEM Officer
|
|
65
|
+
:changed_date: '2024-10-31'
|
|
66
|
+
:88Y0:
|
|
67
|
+
:name: Pathfinder Officer
|
|
68
|
+
:changed_date: '2023-10-31'
|
|
69
|
+
:89A0:
|
|
70
|
+
:name: Air Advisor (Basic)
|
|
71
|
+
:changed_date: '2022-10-31'
|
|
72
|
+
:89B0:
|
|
73
|
+
:name: Air Advisor (Basic) Team Leader
|
|
74
|
+
:changed_date: '2022-10-31'
|
|
75
|
+
:89C0:
|
|
76
|
+
:name: Air Advisor (Basic) Mission Commander
|
|
77
|
+
:changed_date: '2022-10-31'
|
|
78
|
+
:89D0:
|
|
79
|
+
:name: Air Advisor (Advanced)
|
|
80
|
+
:changed_date: '2022-10-31'
|
|
81
|
+
:89E0:
|
|
82
|
+
:name: Air Advisor (Advanced) Team Leader
|
|
83
|
+
:changed_date: '2022-10-31'
|
|
84
|
+
:89F0:
|
|
85
|
+
:name: Air Advisor (Advanced) Mission Commander
|
|
86
|
+
:changed_date: '2022-10-31'
|
|
87
|
+
:89G0:
|
|
88
|
+
:name: Combat Aviation Advisor
|
|
89
|
+
:changed_date: '2025-04-30'
|
|
90
|
+
:89H0:
|
|
91
|
+
:name: Combat Aviation Advisor Team Leader
|
|
92
|
+
:changed_date: '2025-04-30'
|
|
93
|
+
:89I0:
|
|
94
|
+
:name: Combat Aviation Advisor Mission Commander
|
|
95
|
+
:changed_date: '2025-04-30'
|
|
96
|
+
:89W0:
|
|
97
|
+
:name: Weapons of Mass Destruction Civil Support Team (WMD-CST)
|
|
98
|
+
:changed_date: '2020-10-31'
|
|
99
|
+
:90G0:
|
|
100
|
+
:name: General Officer
|
|
101
|
+
:91C0:
|
|
102
|
+
:name: Commander
|
|
103
|
+
:91E0:
|
|
104
|
+
:name: Wing Commander Equivalent
|
|
105
|
+
:91W0:
|
|
106
|
+
:name: Wing Commander
|
|
107
|
+
:92F0:
|
|
108
|
+
:name: Foreign Area Officer (FAO) Trainee
|
|
109
|
+
:92J0:
|
|
110
|
+
:name: Non-designated Lawyer
|
|
111
|
+
:92J1:
|
|
112
|
+
:name: AFROTC Educational Delay Law Student
|
|
113
|
+
:92J2:
|
|
114
|
+
:name: Funded Legal Education Program Law Student
|
|
115
|
+
:92J3:
|
|
116
|
+
:name: Excess Leave Law Student
|
|
117
|
+
:92M0:
|
|
118
|
+
:name: Health Professions Scholarship Program (HPSP) Medical Student
|
|
119
|
+
:92M1:
|
|
120
|
+
:name: Uniformed Services University of Health Sciences (USUHS) Student
|
|
121
|
+
:92M2:
|
|
122
|
+
:name: HPSP Biomedical Science Student
|
|
123
|
+
:92P0:
|
|
124
|
+
:name: Physician Assistant Student
|
|
125
|
+
:changed_date: '2017-10-31'
|
|
126
|
+
:92R0:
|
|
127
|
+
:name: Chaplain Candidate
|
|
128
|
+
:92S0:
|
|
129
|
+
:name: Student Officer Authorization
|
|
130
|
+
:92T0:
|
|
131
|
+
:name: Pilot Trainee
|
|
132
|
+
:92T1:
|
|
133
|
+
:name: Combat Systems Officer Trainee
|
|
134
|
+
:92T2:
|
|
135
|
+
:name: Air Battle Manager Trainee
|
|
136
|
+
:92T3:
|
|
137
|
+
:name: Remotely Piloted Aircraft Pilot Trainee
|
|
138
|
+
:92W0:
|
|
139
|
+
:name: Combat Wounded Warrior
|
|
140
|
+
:changed_date: '2015-05-11'
|
|
141
|
+
:92W1:
|
|
142
|
+
:name: Reserved for Future Use
|
|
143
|
+
:changed_date: '2015-05-11'
|
|
144
|
+
:92W2:
|
|
145
|
+
:name: Combat Wounded Warrior with Exemptions
|
|
146
|
+
:changed_date: '2015-05-11'
|
|
147
|
+
:92W3:
|
|
148
|
+
:name: Non-Combat Wounded Warrior
|
|
149
|
+
:changed_date: '2015-05-11'
|
|
150
|
+
:92W4:
|
|
151
|
+
:name: Wounded Warrior - Limited Assignment Status (LAS)
|
|
152
|
+
:acronym: LAS
|
|
153
|
+
:changed_date: '2015-05-11'
|
|
154
|
+
:92W5:
|
|
155
|
+
:name: Wounded Warrior - Retired/Discharged
|
|
156
|
+
:changed_date: '2015-05-11'
|
|
157
|
+
:92W6:
|
|
158
|
+
:name: Reserved for Future Use
|
|
159
|
+
:changed_date: '2015-05-11'
|
|
160
|
+
:92W7:
|
|
161
|
+
:name: Reserved for Future Use
|
|
162
|
+
:changed_date: '2015-05-11'
|
|
163
|
+
:92W8:
|
|
164
|
+
:name: Wounded Warrior - Ambassador
|
|
165
|
+
:changed_date: '2019-10-31'
|
|
166
|
+
:92W9:
|
|
167
|
+
:name: Wounded Warrior - Project Planner/Officer
|
|
168
|
+
:changed_date: '2019-10-31'
|
|
169
|
+
:93P0:
|
|
170
|
+
:name: Patient
|
|
171
|
+
:94N0:
|
|
172
|
+
:name: Nuclear Weapons Custodian
|
|
173
|
+
:95A0:
|
|
174
|
+
:name: Non-EAD USAFR Academy Liaison Officer (ALO) or Civil Air Patrol Reserve Assistance
|
|
175
|
+
Program (CAPRAP) Officer
|
|
176
|
+
:96A0:
|
|
177
|
+
:name: Disqualified Officer, Reasons Beyond Their Control
|
|
178
|
+
:96B0:
|
|
179
|
+
:name: Disqualified Officer, Reasons Within Their Control
|
|
180
|
+
:96D0:
|
|
181
|
+
:name: Officer Not Available for Use in Awarded AFSC for Cause
|
|
182
|
+
:96U0:
|
|
183
|
+
:name: Unclassified Officer
|
|
184
|
+
:96V0:
|
|
185
|
+
:name: Un-allotted
|
|
186
|
+
:97E0:
|
|
187
|
+
:name: Executive Officer
|
|
188
|
+
:99A0:
|
|
189
|
+
:name: Unspecified AFSC
|
|
190
|
+
:changed_date: '2015-04-20'
|
|
191
|
+
:99G0:
|
|
192
|
+
:name: Gold Bar Recruiter
|
|
193
|
+
:changed_date: '2013-06-05'
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
require "date"
|
|
2
|
+
require "yaml"
|
|
3
|
+
|
|
4
|
+
module GovCodes
|
|
5
|
+
module AFSC
|
|
6
|
+
# Loads the versioned release manifest and per-release specialty index for a
|
|
7
|
+
# publication (DAFECD enlisted, DAFOCD officer), and resolves the release in
|
|
8
|
+
# effect on a given date. Both publications share this resolve/merge/cache
|
|
9
|
+
# machinery and are resolved independently (their release dates may diverge).
|
|
10
|
+
#
|
|
11
|
+
# See docs/plans/2026-07-08-afsc-pdf-representation-design.md
|
|
12
|
+
# "Versioning by document release".
|
|
13
|
+
#
|
|
14
|
+
# Storage layout (per release):
|
|
15
|
+
# lib/gov_codes/afsc/releases.yml # manifest
|
|
16
|
+
# lib/gov_codes/afsc/releases/dafecd/<date>/enlisted.yml # enlisted index
|
|
17
|
+
# lib/gov_codes/afsc/releases/dafocd/<date>/officer.yml # officer index
|
|
18
|
+
#
|
|
19
|
+
# Resolution: releases are sorted ascending by effective_date. A given
|
|
20
|
+
# `as_of` resolves to the release with the greatest effective_date <= as_of;
|
|
21
|
+
# `as_of: nil` resolves it as of today. A release only takes effect on its
|
|
22
|
+
# own effective_date -- a release dated in the future (e.g. one a consumer
|
|
23
|
+
# pre-loads ahead of its official date, see DEC-004 below) is not resolved
|
|
24
|
+
# until that date arrives, even though it is the most recently added one. A
|
|
25
|
+
# date before the earliest shipped release resolves to no release (an empty
|
|
26
|
+
# index / nil date).
|
|
27
|
+
#
|
|
28
|
+
# Extensibility (DEC-004): both tiers merge across the load path (gem lib dir
|
|
29
|
+
# first, then the load path). The manifest's per-publication release list is
|
|
30
|
+
# unioned by effective_date -- a consumer releases.yml ADDS releases without
|
|
31
|
+
# hiding the shipped ones, and a same-date entry from the load path wins. For
|
|
32
|
+
# the resolved release, the release index found at each lookup path is merged,
|
|
33
|
+
# so a consumer may drop gov_codes/afsc/releases/dafecd/<date>/enlisted.yml to
|
|
34
|
+
# extend or override the shipped index.
|
|
35
|
+
module Releases
|
|
36
|
+
# Publication key for the enlisted directory (DAFECD).
|
|
37
|
+
ENLISTED_PUBLICATION = "dafecd"
|
|
38
|
+
# Publication key for the officer directory (DAFOCD).
|
|
39
|
+
OFFICER_PUBLICATION = "dafocd"
|
|
40
|
+
|
|
41
|
+
class << self
|
|
42
|
+
def manifest(lookup: $LOAD_PATH)
|
|
43
|
+
manifest_cache[cache_key(lookup)] ||= load_manifest(lookup)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def enlisted_index(as_of: nil, lookup: $LOAD_PATH)
|
|
47
|
+
release_index(ENLISTED_PUBLICATION, "enlisted.yml", as_of: as_of, lookup: lookup)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def officer_index(as_of: nil, lookup: $LOAD_PATH)
|
|
51
|
+
release_index(OFFICER_PUBLICATION, "officer.yml", as_of: as_of, lookup: lookup)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Resolve the RI/SDI index for +publication+ (enlisted DAFECD or officer
|
|
55
|
+
# DAFOCD). RI/SDI codes live in both directories with different code
|
|
56
|
+
# grammars (5-char enlisted, 4-char officer), so this wrapper is
|
|
57
|
+
# parameterized by publication; it otherwise reuses the same resolve/
|
|
58
|
+
# merge/cache and acronym-overlay machinery as enlisted_index/
|
|
59
|
+
# officer_index.
|
|
60
|
+
def ri_index(as_of: nil, lookup: $LOAD_PATH, publication: ENLISTED_PUBLICATION)
|
|
61
|
+
release_index(publication, "ri.yml", as_of: as_of, lookup: lookup)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Resolve the effective date for +publication+ (defaults to enlisted).
|
|
65
|
+
# Publications are resolved independently: an officer release date never
|
|
66
|
+
# moves the enlisted latest date and vice versa.
|
|
67
|
+
def effective_date_for(as_of: nil, lookup: $LOAD_PATH, publication: ENLISTED_PUBLICATION)
|
|
68
|
+
release = resolve_release(publication, as_of: as_of, lookup: lookup)
|
|
69
|
+
release && release[:date]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def reset!
|
|
73
|
+
@manifest_cache = {}
|
|
74
|
+
@index_cache = {}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def manifest_cache
|
|
80
|
+
@manifest_cache ||= {}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def index_cache
|
|
84
|
+
@index_cache ||= {}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def cache_key(lookup)
|
|
88
|
+
Array(lookup).dup
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def to_date(as_of)
|
|
92
|
+
return nil if as_of.nil?
|
|
93
|
+
return as_of if as_of.is_a?(Date)
|
|
94
|
+
Date.parse(as_of.to_s)
|
|
95
|
+
rescue Date::Error
|
|
96
|
+
raise ArgumentError,
|
|
97
|
+
"invalid as_of #{as_of.inspect}: expected a Date or a parseable date string (e.g. \"2025-10-31\")"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Ordered list of directories to search: the gem lib dir first, then the
|
|
101
|
+
# provided lookup path. Deduplicated to avoid loading the same file twice.
|
|
102
|
+
def lookup_paths(lookup)
|
|
103
|
+
gem_lib_dir = File.expand_path("../..", __dir__)
|
|
104
|
+
([gem_lib_dir] + Array(lookup)).uniq
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Resolve, merge, and cache the release index for +publication+. Always
|
|
108
|
+
# resolves the release first (cheap: the manifest itself is cached) and
|
|
109
|
+
# keys the expensive part -- loading and merging the index file -- on
|
|
110
|
+
# the RESOLVED release's date, not the raw +as_of+. With `as_of: nil`
|
|
111
|
+
# meaning "today," keying on the raw input would freeze the cache to
|
|
112
|
+
# whichever release happened to be current at the first call and never
|
|
113
|
+
# notice a later release's effective_date arrive.
|
|
114
|
+
def release_index(publication, index_file, as_of:, lookup:)
|
|
115
|
+
release = resolve_release(publication, as_of: as_of, lookup: lookup)
|
|
116
|
+
return {} unless release
|
|
117
|
+
|
|
118
|
+
key = [publication, index_file, release[:date], cache_key(lookup)]
|
|
119
|
+
index_cache[key] ||= load_release_index(release, publication, index_file, lookup)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def resolve_release(publication, as_of:, lookup:)
|
|
123
|
+
releases = release_list(lookup, publication)
|
|
124
|
+
return nil if releases.empty?
|
|
125
|
+
|
|
126
|
+
target = to_date(as_of) || Date.today
|
|
127
|
+
releases.reverse.find { |r| r[:date] <= target }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Releases for +publication+ from the manifest, each annotated with a
|
|
131
|
+
# parsed :date and the :dir name used to locate its index, sorted
|
|
132
|
+
# ascending by date.
|
|
133
|
+
def release_list(lookup, publication)
|
|
134
|
+
entries = manifest(lookup: lookup)[publication.to_sym] || []
|
|
135
|
+
entries.filter_map do |entry|
|
|
136
|
+
raw = entry[:effective_date]
|
|
137
|
+
next unless raw
|
|
138
|
+
|
|
139
|
+
date = raw.is_a?(Date) ? raw : Date.parse(raw.to_s)
|
|
140
|
+
dir = raw.is_a?(Date) ? raw.strftime("%Y-%m-%d") : raw.to_s
|
|
141
|
+
entry.merge(date: date, dir: dir)
|
|
142
|
+
end.sort_by { |r| r[:date] }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def load_manifest(lookup)
|
|
146
|
+
merged = {}
|
|
147
|
+
each_yaml(lookup, ["gov_codes", "afsc", "releases.yml"]) do |data|
|
|
148
|
+
merge_manifest!(merged, data)
|
|
149
|
+
end
|
|
150
|
+
merged
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def load_release_index(release, publication, index_file, lookup)
|
|
154
|
+
merged = {}
|
|
155
|
+
parts = ["gov_codes", "afsc", "releases", publication, release[:dir], index_file]
|
|
156
|
+
each_yaml(lookup, parts) { |data| merged.merge!(data) }
|
|
157
|
+
apply_acronym_overlay(merged, publication, release, lookup)
|
|
158
|
+
merged
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Apply the consumer acronym overlay (Task 3, "dedicated tier"): a flat
|
|
162
|
+
# SPECIALTY => ACRONYM map at releases/<pub>/<dir>/acronyms.yml, resolved
|
|
163
|
+
# for the SAME release as the index and merged across the load path
|
|
164
|
+
# (consumer-loaded-last wins). Applied ONLY to specialties already in the
|
|
165
|
+
# index, so it sets an entry's :acronym without ever replacing the entry
|
|
166
|
+
# (the index merge above is shallow — overlaying whole entries here would
|
|
167
|
+
# drop name/skill_levels/shredouts). The consumer overlay wins over the
|
|
168
|
+
# source-extracted acronym shipped in the index. The gem ships no
|
|
169
|
+
# acronyms.yml; an absent overlay leaves the index unchanged. Resolved per
|
|
170
|
+
# release, so overlays never leak across document dates or publications.
|
|
171
|
+
def apply_acronym_overlay(merged, publication, release, lookup)
|
|
172
|
+
overlay = {}
|
|
173
|
+
parts = ["gov_codes", "afsc", "releases", publication, release[:dir], "acronyms.yml"]
|
|
174
|
+
each_yaml(lookup, parts) { |data| overlay.merge!(data) }
|
|
175
|
+
overlay.each do |specialty, acronym|
|
|
176
|
+
entry = merged[specialty]
|
|
177
|
+
entry[:acronym] = acronym if entry
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Merge a manifest hash into +acc+. Per-publication release lists are
|
|
182
|
+
# unioned by effective_date (a same-date entry from a later-loaded file
|
|
183
|
+
# wins); non-list values fall back to a plain overwrite.
|
|
184
|
+
def merge_manifest!(acc, incoming)
|
|
185
|
+
incoming.each do |publication, releases|
|
|
186
|
+
acc[publication] = if acc[publication].is_a?(Array) && releases.is_a?(Array)
|
|
187
|
+
union_releases(acc[publication], releases)
|
|
188
|
+
else
|
|
189
|
+
releases
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
acc
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def union_releases(base, overrides)
|
|
196
|
+
by_date = {}
|
|
197
|
+
(base + overrides).each do |entry|
|
|
198
|
+
by_date[entry[:effective_date]] = entry if entry.is_a?(Hash)
|
|
199
|
+
end
|
|
200
|
+
by_date.values
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Yield each parsed YAML hash found at +parts+ across the lookup paths,
|
|
204
|
+
# skipping missing files and malformed YAML (graceful degradation).
|
|
205
|
+
def each_yaml(lookup, parts)
|
|
206
|
+
lookup_paths(lookup).each do |dir|
|
|
207
|
+
path = File.join(dir, *parts)
|
|
208
|
+
next unless File.exist?(path)
|
|
209
|
+
|
|
210
|
+
data = YAML.load_file(path, symbolize_names: true)
|
|
211
|
+
yield data if data.is_a?(Hash)
|
|
212
|
+
rescue Psych::SyntaxError, TypeError
|
|
213
|
+
next
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
:dafecd:
|
|
3
|
+
- :effective_date: '2025-10-31'
|
|
4
|
+
:version_label: v3.5
|
|
5
|
+
:source: DAFECD -31 October 25 v3.5 FINAL.pdf
|
|
6
|
+
:name: Department of the Air Force Enlisted Classification Directory
|
|
7
|
+
:dafocd:
|
|
8
|
+
- :effective_date: '2025-10-31'
|
|
9
|
+
:version_label: v3
|
|
10
|
+
:source: DAFOCD 31 Oct 25 v3.pdf
|
|
11
|
+
:name: Department of the Air Force Officer Classification Directory
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
require "strscan"
|
|
2
|
+
require_relative "releases"
|
|
3
|
+
|
|
4
|
+
module GovCodes
|
|
5
|
+
module AFSC
|
|
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:
|
|
9
|
+
#
|
|
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.
|
|
18
|
+
module RI
|
|
19
|
+
class Parser
|
|
20
|
+
def initialize(code)
|
|
21
|
+
@code = code.to_s
|
|
22
|
+
end
|
|
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.
|
|
29
|
+
def parse
|
|
30
|
+
parse_enlisted || parse_officer || empty_result
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def empty_result
|
|
36
|
+
{
|
|
37
|
+
career_group: nil,
|
|
38
|
+
career_field: nil,
|
|
39
|
+
identifier: nil,
|
|
40
|
+
suffix: nil,
|
|
41
|
+
specific_ri: nil,
|
|
42
|
+
publication: nil
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
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
|
|
53
|
+
suffix = scanner.scan(/[A-Z]/)
|
|
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
|
|
66
|
+
|
|
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
|
+
}
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
CODES = {}
|
|
97
|
+
private_constant :CODES
|
|
98
|
+
|
|
99
|
+
Code = Data.define(
|
|
100
|
+
:career_group,
|
|
101
|
+
:career_field,
|
|
102
|
+
:identifier,
|
|
103
|
+
:suffix,
|
|
104
|
+
:specific_ri,
|
|
105
|
+
:name,
|
|
106
|
+
:acronym,
|
|
107
|
+
:effective_date
|
|
108
|
+
)
|
|
109
|
+
|
|
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
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
nil
|
|
173
|
+
end
|
|
174
|
+
|
|
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
|
|
180
|
+
|
|
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
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
codes.select { |code| code.start_with?(prefix) }
|
|
194
|
+
.map { |code| find(code, as_of: as_of) }
|
|
195
|
+
.compact
|
|
196
|
+
end
|
|
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.
|
|
201
|
+
def self.reset_data(lookup: $LOAD_PATH)
|
|
202
|
+
Releases.reset!
|
|
203
|
+
CODES.clear
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
data/lib/gov_codes/afsc.rb
CHANGED
|
@@ -1,16 +1,48 @@
|
|
|
1
1
|
require_relative "afsc/enlisted"
|
|
2
2
|
require_relative "afsc/officer"
|
|
3
|
+
require_relative "afsc/ri"
|
|
3
4
|
|
|
4
5
|
module GovCodes
|
|
5
6
|
module AFSC
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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)
|
|
16
|
+
end
|
|
17
|
+
|
|
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)
|
|
35
|
+
results = []
|
|
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))
|
|
39
|
+
results
|
|
9
40
|
end
|
|
10
41
|
|
|
11
42
|
def self.reset_data(lookup: $LOAD_PATH)
|
|
12
43
|
Enlisted.reset_data(lookup:)
|
|
13
44
|
Officer.reset_data(lookup:)
|
|
45
|
+
RI.reset_data(lookup:)
|
|
14
46
|
end
|
|
15
47
|
end
|
|
16
48
|
end
|