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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 11433a91ce2be5af9141fa58c9110a9d0f383b9769550f2fc95843b27d32da6b
|
|
4
|
+
data.tar.gz: 49d09edca49ae863444781be3da6eff64cffa2f499b8442d002736e7dc8624be
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8dc82c96d74319e22b9d24d8ecc2f9c827eec171921f28158118fde4dc3586dcaa1ca5c06bd0827b954e5e659e5471eed404e7b704ad1d60f8231d9df6857e17
|
|
7
|
+
data.tar.gz: 4091a9fe8b0e0922817a01aeaaf0c48faf88339fb80431b7556975fa41c0e0c4445cc422dbcae782b91a1a8a1a4414d8ca3de8894b33af5a0742050bb73e4e3a
|
data/.simplecov
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
# Set command name before starting to ensure consistent tracking
|
|
2
|
+
SimpleCov.command_name "Unit Tests"
|
|
3
|
+
|
|
1
4
|
SimpleCov.start do
|
|
2
5
|
# enable_coverage :branch
|
|
3
6
|
|
|
@@ -12,6 +15,15 @@ SimpleCov.start do
|
|
|
12
15
|
track_files "lib/**/*.rb"
|
|
13
16
|
|
|
14
17
|
# Group files by module
|
|
15
|
-
|
|
16
18
|
add_group "AFSC", "lib/gov_codes/afsc"
|
|
19
|
+
|
|
20
|
+
# Add JSON formatter for easier parsing
|
|
21
|
+
require "simplecov_json_formatter"
|
|
22
|
+
formatter SimpleCov::Formatter::MultiFormatter.new([
|
|
23
|
+
SimpleCov::Formatter::HTMLFormatter,
|
|
24
|
+
SimpleCov::Formatter::JSONFormatter
|
|
25
|
+
])
|
|
26
|
+
|
|
27
|
+
# Enable result merging
|
|
28
|
+
enable_coverage_for_eval
|
|
17
29
|
end
|
data/.tool-versions
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
ruby
|
|
1
|
+
ruby 4.0.5
|
data/CHANGELOG.md
CHANGED
|
@@ -5,16 +5,40 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
|
6
6
|
and this project adheres to [Semantic Versioning](http://semver.org/).
|
|
7
7
|
|
|
8
|
-
## [0.1.
|
|
8
|
+
## [0.1.2] - 2026-07-16
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- 6C0X1 Contracting (4a6c87e)
|
|
13
|
+
- 6F0X1 Financial Management and Comptroller (4a6c87e)
|
|
14
|
+
- 7S0X1 Special Investigations (4a6c87e)
|
|
15
|
+
- 5Z700/5Z800/5Z900 Chief Enlisted Manager codes (4a6c87e)
|
|
16
|
+
- Concrete skill-level AFSC code lookup (e.g. 1A172Y) (1cea6af)
|
|
17
|
+
- Version-aware lookup via find(code, as_of:) with effective_date (1cea6af)
|
|
18
|
+
- Officer AFSC lookups versioned by DAFOCD release with as_of (1a43a2d)
|
|
19
|
+
- Officer qualification-level titles and specialty/shredout acronyms (e.g. TACPO) (1a43a2d)
|
|
20
|
+
- Officer specialties 17DX, 44BX, 45AX, 47BX, 47PX, 48GX from the DAFOCD (b28f308)
|
|
21
|
+
- RI/SDI extraction pipeline (parser, index builder, CLI) for DAFECD and DAFOCD (5d068dd)
|
|
22
|
+
- versioned RI/SDI release data at releases/{dafecd,dafocd}/2025-10-31/ri.yml (de03f09)
|
|
23
|
+
- RI.find_by_acronym as a real reverse lookup; as_of threading through RI.find and RI.search (712f624)
|
|
9
24
|
|
|
10
25
|
### Changed
|
|
11
26
|
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
27
|
+
- Set dependabot cooldown to 14 days (fde4b9a)
|
|
28
|
+
- Test against Ruby 4.0.5 (61983ce)
|
|
29
|
+
- Source enlisted AFSC data from the official DAFECD instead of Wikipedia (1cea6af)
|
|
30
|
+
- Source officer AFSC data from the official DAFOCD instead of Wikipedia (1a43a2d)
|
|
31
|
+
- as_of defaults to today, not the newest release regardless of date (ff86c00)
|
|
32
|
+
- RI resolves from the versioned DAFECD/DAFOCD release indexes instead of Wikipedia-sourced data (712f624)
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- SimpleCov reporting inaccurate coverage due to at_exit ordering (de7063c)
|
|
37
|
+
- glued shredout names in the enlisted release data (be773cf)
|
|
38
|
+
- required_ruby_version understated the actual floor (b8d64b8)
|
|
39
|
+
- officer ladder missed no-comma, glued-shred, and glyph-prefixed cards (b28f308)
|
|
40
|
+
|
|
41
|
+
## [0.1.1] - 2025-11-17
|
|
18
42
|
|
|
19
43
|
### Added
|
|
20
44
|
|
|
@@ -25,16 +49,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|
|
25
49
|
- Reporting identifiers with GovCodes::AFSC::RI (6102656)
|
|
26
50
|
- AFSC.search to return an array of matching codes for the given prefix. (5c5bdf1)
|
|
27
51
|
|
|
28
|
-
### Removed
|
|
29
|
-
|
|
30
|
-
- 824 hallucinated codes not found in Wikipedia (4ca6aef)
|
|
31
|
-
|
|
32
|
-
### Fixed
|
|
33
|
-
|
|
34
|
-
- Tests updated to use real Wikipedia codes (4ca6aef)
|
|
35
|
-
|
|
36
|
-
## [0.1.1] - 2025-11-17
|
|
37
|
-
|
|
38
52
|
### Changed
|
|
39
53
|
|
|
40
54
|
- Test against Ruby 3.3.8 and 3.4.3
|
|
@@ -44,15 +58,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|
|
44
58
|
- Enlisted/Officer lookups to handle String leaf values (4ca6aef)
|
|
45
59
|
- Use git trailers to track changelog changes. (c83929a)
|
|
46
60
|
|
|
47
|
-
### Added
|
|
48
|
-
|
|
49
|
-
- AFSC Officer support (bb96aaa)
|
|
50
|
-
- Data extractor script for Wikipedia (c8ec55b)
|
|
51
|
-
- 1Z Special Warfare career field (Pararescue, Combat Control, TACP) (4ca6aef)
|
|
52
|
-
- Nokogiri gem for HTML parsing (4ca6aef)
|
|
53
|
-
- Reporting identifiers with GovCodes::AFSC::RI (6102656)
|
|
54
|
-
- AFSC.search to return an array of matching codes for the given prefix. (5c5bdf1)
|
|
55
|
-
|
|
56
61
|
### Removed
|
|
57
62
|
|
|
58
63
|
- 824 hallucinated codes not found in Wikipedia (4ca6aef)
|
data/README.md
CHANGED
|
@@ -31,28 +31,79 @@ require 'gov_codes/afsc'
|
|
|
31
31
|
|
|
32
32
|
# Find an enlisted AFSC code
|
|
33
33
|
code = GovCodes::AFSC.find("1A1X2")
|
|
34
|
-
puts code.name # => "Mobility
|
|
34
|
+
puts code.name # => "Mobility Force Aviator"
|
|
35
35
|
puts code.career_field # => "1A"
|
|
36
36
|
puts code.career_field_subdivision # => "1A1"
|
|
37
37
|
puts code.skill_level # => "X"
|
|
38
38
|
puts code.specific_afsc # => "1A1X2"
|
|
39
39
|
puts code.shredout # => nil
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
puts code.effective_date # => #<Date: 2025-10-31>
|
|
41
|
+
|
|
42
|
+
# Look up the concrete code an HR system actually stores
|
|
43
|
+
# (the "7" is the airman's skill level: Craftsman)
|
|
44
|
+
code = GovCodes::AFSC.find("1A172Y")
|
|
45
|
+
code.specialty_name # => "Mobility Force Aviator"
|
|
46
|
+
code.skill_level_number # => 7
|
|
47
|
+
code.skill_level_name # => "Craftsman" (title comes from the directory)
|
|
48
|
+
code.specialty # => :"1A1X2"
|
|
49
|
+
|
|
50
|
+
# shredout_name resolves the shredout's meaning when that shredout
|
|
51
|
+
# exists in the data (nil otherwise)
|
|
52
|
+
code = GovCodes::AFSC.find("1A172A")
|
|
53
|
+
code.shredout_name # => "C-5 Flight Engineer"
|
|
54
|
+
|
|
55
|
+
# Enlisted lookups are versioned by DAFECD release (published each 30 Apr and
|
|
56
|
+
# 31 Oct). `find`/`search` default to today's date; pass `as_of:` (a Date or
|
|
57
|
+
# "YYYY-MM-DD" string) to resolve the release in effect on a different date. A
|
|
58
|
+
# release only takes effect on its own effective_date, even if it's the most
|
|
59
|
+
# recently added one (e.g. one you pre-load ahead of its official date).
|
|
60
|
+
code = GovCodes::AFSC.find("1A172Y", as_of: "2025-11-01")
|
|
61
|
+
code.effective_date # => #<Date: 2025-10-31>
|
|
62
|
+
# A date before the earliest shipped release has no data and returns nil.
|
|
63
|
+
GovCodes::AFSC.find("1A172Y", as_of: "2000-01-01") # => nil
|
|
64
|
+
|
|
65
|
+
# Find an officer AFSC code. Officer lookups are versioned by DAFOCD release
|
|
66
|
+
# (like enlisted); `find`/`search` default to today's date and accept the
|
|
67
|
+
# same `as_of:`.
|
|
42
68
|
code = GovCodes::AFSC.find("11MX")
|
|
43
|
-
puts code.name # => "Mobility
|
|
69
|
+
puts code.name # => "Mobility Pilot"
|
|
44
70
|
puts code.career_group # => "11"
|
|
45
71
|
puts code.functional_area # => "M"
|
|
46
72
|
puts code.qualification_level # => "X"
|
|
47
73
|
puts code.shredout # => nil
|
|
74
|
+
puts code.effective_date # => #<Date: 2025-10-31>
|
|
75
|
+
|
|
76
|
+
# A concrete qualification level derives the X-form specialty and its title
|
|
77
|
+
code = GovCodes::AFSC.find("11B3")
|
|
78
|
+
code.specialty # => :"11BX"
|
|
79
|
+
code.specialty_name # => "Bomber Pilot"
|
|
80
|
+
code.qualification_level_number # => 3
|
|
81
|
+
code.qualification_level_name # => "Aircraft Commander" (title from the directory)
|
|
82
|
+
|
|
83
|
+
# Some officer specialties are keyed by a literal bare code (a single command/
|
|
84
|
+
# materiel-leader position, not a qualification ladder): 10C0, 62S0, 63G0, 63S0.
|
|
85
|
+
# These resolve ONLY via their literal code -- there is no X-form for them, so
|
|
86
|
+
# find("10CX") / find("62SX") return nil by design.
|
|
87
|
+
GovCodes::AFSC.find("10C0").name # => "Operations Commander"
|
|
88
|
+
GovCodes::AFSC.find("10CX") # => nil (bare-code specialty; no X-form)
|
|
89
|
+
|
|
90
|
+
# An officer code carries its acronym; a shredout acronym wins for a shredded code
|
|
91
|
+
GovCodes::AFSC.find("16F3").acronym # => "FAO"
|
|
92
|
+
GovCodes::AFSC.find("19ZXB").acronym # => "TACPO"
|
|
48
93
|
|
|
49
94
|
# Find a Reporting Identifier (RI) or Special Duty Identifier (SDI)
|
|
50
95
|
code = GovCodes::AFSC.find("8A400")
|
|
51
|
-
puts code.name # => "Talent
|
|
96
|
+
puts code.name # => "Talent Management Consultant"
|
|
52
97
|
puts code.career_field # => "8A"
|
|
53
98
|
puts code.identifier # => "400"
|
|
54
99
|
puts code.suffix # => nil
|
|
55
100
|
|
|
101
|
+
# An RI/SDI code carries its source-verified acronym, and reverse lookup is
|
|
102
|
+
# case-insensitive and as_of-aware, just like enlisted/officer AFSCs:
|
|
103
|
+
GovCodes::AFSC.find("9M200").acronym # => "IHS"
|
|
104
|
+
GovCodes::AFSC.find_by_acronym("IHS", as_of: "2025-11-01").name
|
|
105
|
+
# => "International Health Specialists (IHS)"
|
|
106
|
+
|
|
56
107
|
# Find a code with a shredout/suffix
|
|
57
108
|
code = GovCodes::AFSC.find("11BXA")
|
|
58
109
|
puts code.name # => "B-1"
|
|
@@ -72,9 +123,9 @@ results.each do |code|
|
|
|
72
123
|
end
|
|
73
124
|
# Output:
|
|
74
125
|
# 1Z1X1: Pararescue
|
|
75
|
-
# 1Z2X1: Combat
|
|
76
|
-
# 1Z3X1: Tactical
|
|
77
|
-
# 1Z4X1: Special
|
|
126
|
+
# 1Z2X1: Combat Control
|
|
127
|
+
# 1Z3X1: Tactical Air Control Party (TACP)
|
|
128
|
+
# 1Z4X1: Special Reconnaissance
|
|
78
129
|
|
|
79
130
|
# Search for Bomber Pilot shredouts
|
|
80
131
|
results = GovCodes::AFSC.search("11BX")
|
|
@@ -95,36 +146,140 @@ GovCodes::AFSC.search("1z1") # Same as search("1Z1")
|
|
|
95
146
|
|
|
96
147
|
### Extending with Custom AFSC Codes
|
|
97
148
|
|
|
98
|
-
|
|
149
|
+
Enlisted codes are stored as a specialty-keyed index per DAFECD release. You can
|
|
150
|
+
extend or override a release by dropping an index file for that release's
|
|
151
|
+
effective date onto your application's load path:
|
|
152
|
+
|
|
153
|
+
```yaml
|
|
154
|
+
# In your application's lib/gov_codes/afsc/releases/dafecd/2025-10-31/enlisted.yml
|
|
155
|
+
:"9Z9X9":
|
|
156
|
+
:name: Custom Specialty
|
|
157
|
+
:career_field: :"9Z"
|
|
158
|
+
:skill_levels:
|
|
159
|
+
7:
|
|
160
|
+
:code: 9Z979
|
|
161
|
+
:title: Craftsman
|
|
162
|
+
:shredouts:
|
|
163
|
+
:A: Custom Shredout
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The gem merges your index over the shipped index for the matching release,
|
|
167
|
+
adding new specialties and overriding existing ones.
|
|
168
|
+
|
|
169
|
+
You can also add a whole new release (e.g. a newer directory the gem has not
|
|
170
|
+
shipped yet) by listing it in a `releases.yml` on your load path. Release lists
|
|
171
|
+
are unioned by effective date, so adding a release never hides the shipped ones;
|
|
172
|
+
a same-date entry from your file overrides the shipped manifest entry:
|
|
173
|
+
|
|
174
|
+
```yaml
|
|
175
|
+
# In your application's lib/gov_codes/afsc/releases.yml
|
|
176
|
+
:dafecd:
|
|
177
|
+
- :effective_date: '2026-04-30'
|
|
178
|
+
:version_label: v3.6
|
|
179
|
+
:source: DAFECD-30-April-26.pdf
|
|
180
|
+
:name: Department of the Air Force Enlisted Classification Directory
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Pair it with a matching `releases/dafecd/2026-04-30/enlisted.yml` index, and
|
|
184
|
+
`find(code, as_of: "2026-04-30")` will resolve against it.
|
|
185
|
+
|
|
186
|
+
## Data source & provenance
|
|
187
|
+
|
|
188
|
+
AFSC data is extracted from the official **Department of the Air Force classification directories** — the DAFECD (enlisted) and DAFOCD (officer) — not third-party sources. Extraction is deterministic, and every code is verified to appear verbatim in the source directory: no predicted or hallucinated codes.
|
|
189
|
+
|
|
190
|
+
The data is **versioned by each directory's effective date** (the directories are republished roughly semi-annually, on 30 April and 31 October). Look a code up as it stands today, or as it stood for a given release:
|
|
191
|
+
|
|
192
|
+
```ruby
|
|
193
|
+
GovCodes::AFSC.find("1A172Y") # the release in effect today
|
|
194
|
+
GovCodes::AFSC.find("1A172Y", as_of: "2025-11-01") # the release in effect on that date
|
|
195
|
+
GovCodes::AFSC.find("1A172Y").effective_date # => the release the result came from
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
`as_of: nil` (the default) is today's date, not "whatever's newest" — a release only takes effect on its own effective_date. This only differs from "the latest shipped release" if a release dated in the future has been added to the load path (see "Extending with Custom AFSC Codes" above); until that date arrives, lookups keep resolving against the current release.
|
|
199
|
+
|
|
200
|
+
**Currently shipped:** enlisted AFSCs from the DAFECD and officer AFSCs from the DAFOCD, plus the reporting/special-duty identifiers (RI/SDI) from both directories (enlisted 5-char from the DAFECD, officer 4-char from the DAFOCD) — all effective 31 October 2025. SEIs, prefixes, and the full Space Force Specialty Codes (SFSC) are planned.
|
|
201
|
+
|
|
202
|
+
### Specialty acronyms
|
|
203
|
+
|
|
204
|
+
A resolved code carries an `acronym` field:
|
|
99
205
|
|
|
100
206
|
```ruby
|
|
101
|
-
#
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
207
|
+
GovCodes::AFSC.find("1C8X3").acronym # => "RAWS"
|
|
208
|
+
GovCodes::AFSC.find("1Z3X1").acronym # => "TACP"
|
|
209
|
+
GovCodes::AFSC.find("1A1X2").acronym # => nil (no acronym)
|
|
210
|
+
|
|
211
|
+
# Reverse lookup by acronym (case-insensitive, and `as_of`-aware — it searches
|
|
212
|
+
# the acronyms of the release in effect on that date, so overlays never leak
|
|
213
|
+
# across document dates):
|
|
214
|
+
GovCodes::AFSC.find_by_acronym("RAWS") # => the 1C8X3 Code
|
|
215
|
+
GovCodes::AFSC.find_by_acronym("raws") # => same (case-insensitive)
|
|
216
|
+
GovCodes::AFSC.find_by_acronym("PJ", as_of: "2025-11-01") # => resolves that release's overlay
|
|
217
|
+
GovCodes::AFSC.find_by_acronym("nope") # => nil
|
|
218
|
+
|
|
219
|
+
# Officer acronyms resolve too — including shredout acronyms, which return the
|
|
220
|
+
# concrete shredded code:
|
|
221
|
+
GovCodes::AFSC.find_by_acronym("TACPO") # => the 19ZXB Code (Special Warfare, TACP Officer)
|
|
222
|
+
GovCodes::AFSC.find_by_acronym("FAO") # => the 16FX Code (Foreign Area Officer)
|
|
110
223
|
```
|
|
111
224
|
|
|
112
|
-
|
|
225
|
+
Shipped acronyms are **source-verified**: they are captured only from a trailing parenthetical in the directory title (e.g. `Radar, Airfield & Weather Systems (RAWS)`) and the build gate rejects any acronym that does not appear verbatim in the source title. Five enlisted specialties ship an acronym this way (`1A8X1`, `1C8X3`, `1N1X1`, `1Z3X1`, `4B0X1`); nothing else is invented.
|
|
226
|
+
|
|
227
|
+
Everything else — colloquial acronyms not printed in the directory (e.g. `PJ` for Pararescue) — is **consumer-curated** via an optional overlay you drop on your load path. Overlays are flat `SPECIALTY => ACRONYM` (or `CODE => ACRONYM`) maps:
|
|
228
|
+
|
|
229
|
+
```yaml
|
|
230
|
+
# Enlisted, per DAFECD release date:
|
|
231
|
+
# lib/gov_codes/afsc/releases/dafecd/2025-10-31/acronyms.yml
|
|
232
|
+
:"1Z1X1": PJ # augments the Pararescue entry; name/skill_levels/shredouts untouched
|
|
233
|
+
:"1Z3X1": JTAC # overrides the shipped TACP acronym
|
|
234
|
+
|
|
235
|
+
# Officer, per DAFOCD release date:
|
|
236
|
+
# lib/gov_codes/afsc/releases/dafocd/2025-10-31/acronyms.yml
|
|
237
|
+
:"11MX": MOBPLT # augments the Mobility Pilot entry (ships no acronym)
|
|
238
|
+
:"16FX": FAREA # overrides the shipped FAO acronym
|
|
239
|
+
|
|
240
|
+
# RI/SDI acronyms live in the SAME per-release acronyms.yml, keyed by RI code:
|
|
241
|
+
# 5-char enlisted RI in the DAFECD file, 4-char officer RI in the DAFOCD file.
|
|
242
|
+
:"8A400": TMC # enlisted RI/SDI -> dafecd/<date>/acronyms.yml
|
|
243
|
+
:"90G0": GENOFF # officer RI/SDI -> dafocd/<date>/acronyms.yml
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Key points:
|
|
247
|
+
|
|
248
|
+
- **Enlisted, officer, and RI/SDI overlays are all per-release-date**, scoped by the directory's effective date, so `find(code, as_of: "2025-11-01")` applies that release's overlay — and each publication resolves independently, so an officer overlay never affects an enlisted lookup or vice versa. RI/SDI reuses the same per-release, per-publication `acronyms.yml` tier (enlisted RI from the DAFECD file, officer RI from the DAFOCD file), so an RI overlay never crosses into an enlisted/officer lookup either.
|
|
249
|
+
- The overlay is a **separate tier** — it only sets `:acronym` on entries that already exist and never replaces an entry, so `name`, `qual_levels`/`skill_levels`, and `shredouts` stay intact.
|
|
250
|
+
- **Precedence:** the consumer overlay wins over the shipped, source-verified acronym. For officer codes a documented shredout acronym still wins for a shredded code. The gem ships no overlay file, so absent one the shipped index is used as-is.
|
|
113
251
|
|
|
114
252
|
## Development
|
|
115
253
|
|
|
116
254
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
117
255
|
|
|
118
|
-
|
|
256
|
+
### Regenerating the data
|
|
119
257
|
|
|
120
|
-
|
|
258
|
+
Enlisted (DAFECD) and officer (DAFOCD) indexes are regenerated deterministically
|
|
259
|
+
from the official classification-directory PDFs by `bin/extract_afsc_from_pdf.rb`
|
|
260
|
+
(offline dev tooling; never loaded by the gem runtime). It writes the versioned
|
|
261
|
+
release artifact under `lib/gov_codes/afsc/releases/<publication>/<date>/`, updates
|
|
262
|
+
the manifest, and fails loudly before writing if any code, title, acronym, or
|
|
263
|
+
shredout override is not grounded in the source:
|
|
264
|
+
|
|
265
|
+
```bash
|
|
266
|
+
mise exec -- ruby bin/extract_afsc_from_pdf.rb "DAFECD -31 October 25 v3.5 FINAL.pdf"
|
|
267
|
+
mise exec -- ruby bin/extract_afsc_from_pdf.rb "DAFOCD 31 Oct 25 v3.pdf"
|
|
268
|
+
```
|
|
121
269
|
|
|
122
|
-
|
|
270
|
+
The retired `bin/extract_afsc_from_wikipedia.rb` (which generated the old flat
|
|
271
|
+
enlisted/officer YAML) has been removed in favor of the PDF extractor. RI/SDI
|
|
272
|
+
data is now sourced from the same DAFECD (enlisted, 5-char) and DAFOCD (officer,
|
|
273
|
+
4-char) directories, under the same anti-hallucination gate — every RI code,
|
|
274
|
+
title, and acronym must appear verbatim in the source — and ships as versioned
|
|
275
|
+
artifacts (`releases/<publication>/<date>/ri.yml`) alongside the enlisted and
|
|
276
|
+
officer indexes.
|
|
123
277
|
|
|
124
|
-
To
|
|
278
|
+
To install this gem onto your local machine, run `bundle exec rake install`.
|
|
279
|
+
|
|
280
|
+
This project is managed with [Reissue](https://github.com/SOFware/reissue).
|
|
125
281
|
|
|
126
|
-
|
|
127
|
-
2. `bundle exec rake release`
|
|
282
|
+
Releases are automated via the [shared release workflow](https://github.com/SOFware/reissue/blob/main/.github/workflows/SHARED_WORKFLOW_README.md). Trigger a release by running the "Release gem to RubyGems.org" workflow from the Actions tab.
|
|
128
283
|
|
|
129
284
|
## Contributing
|
|
130
285
|
|
data/Rakefile
CHANGED
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
require "bundler/gem_tasks"
|
|
4
4
|
require "minitest/test_task"
|
|
5
5
|
|
|
6
|
-
Minitest::TestTask.create
|
|
6
|
+
Minitest::TestTask.create do |t|
|
|
7
|
+
# Load test_helper before minitest/autorun to ensure SimpleCov's at_exit
|
|
8
|
+
# handler runs AFTER tests complete (at_exit runs in LIFO order)
|
|
9
|
+
t.test_prelude = 'require "test_helper"'
|
|
10
|
+
end
|
|
7
11
|
|
|
8
12
|
require "standard/rake"
|
|
9
13
|
|
|
@@ -13,5 +17,6 @@ require "reissue/gem"
|
|
|
13
17
|
|
|
14
18
|
Reissue::Task.create :reissue do |task|
|
|
15
19
|
task.version_file = "lib/gov_codes/version.rb"
|
|
16
|
-
task.fragment = :git
|
|
20
|
+
task.fragment = :git
|
|
21
|
+
task.push_finalize = :branch
|
|
17
22
|
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
1a8f6a45b4e04a3a4502853a10d91f9684a7adeebad467b8b97f1904161bf4629a6ebf86ad177d73be605a484a521d9f4f36e289b301500d841bdc9887c31f6a
|
|
@@ -1,10 +1,17 @@
|
|
|
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
6
|
module Enlisted
|
|
7
|
+
SKILL_LEVELS = {
|
|
8
|
+
1 => "Helper",
|
|
9
|
+
3 => "Apprentice",
|
|
10
|
+
5 => "Journeyman",
|
|
11
|
+
7 => "Craftsman",
|
|
12
|
+
9 => "Senior Enlisted Leader"
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
8
15
|
class Parser
|
|
9
16
|
def initialize(code)
|
|
10
17
|
@code = code
|
|
@@ -18,6 +25,10 @@ module GovCodes
|
|
|
18
25
|
career_field: nil,
|
|
19
26
|
career_field_subdivision: nil,
|
|
20
27
|
skill_level: nil,
|
|
28
|
+
skill_level_number: nil,
|
|
29
|
+
skill_level_name: nil,
|
|
30
|
+
specialty: nil,
|
|
31
|
+
specialty_name: nil,
|
|
21
32
|
specific_afsc: nil,
|
|
22
33
|
subcategory: nil,
|
|
23
34
|
shredout: nil
|
|
@@ -41,20 +52,29 @@ module GovCodes
|
|
|
41
52
|
return result unless subdivision_digit
|
|
42
53
|
result[:career_field_subdivision] = :"#{result[:career_field]}#{subdivision_digit}"
|
|
43
54
|
|
|
44
|
-
# Scan for skill level
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
55
|
+
# Scan for skill level: an X placeholder OR a concrete digit.
|
|
56
|
+
# NOTE: any digit is accepted here for now; validating that it is a
|
|
57
|
+
# real skill level (1/3/5/7/9) is deferred to Phase C.
|
|
58
|
+
skill_char = scanner.scan(/[A-Z0-9]/)
|
|
59
|
+
return result unless skill_char
|
|
60
|
+
result[:skill_level] = skill_char.to_sym
|
|
61
|
+
|
|
62
|
+
# Concrete skill level -> numeric level + standard title
|
|
63
|
+
if skill_char.match?(/\d/)
|
|
64
|
+
result[:skill_level_number] = Integer(skill_char)
|
|
65
|
+
result[:skill_level_name] = SKILL_LEVELS[result[:skill_level_number]]
|
|
66
|
+
end
|
|
48
67
|
|
|
49
|
-
# Scan for
|
|
50
|
-
|
|
51
|
-
return result unless
|
|
68
|
+
# Scan for the specific AFSC digit
|
|
69
|
+
specific_digit = scanner.scan(/\d/)
|
|
70
|
+
return result unless specific_digit
|
|
52
71
|
|
|
53
|
-
#
|
|
54
|
-
result[:subcategory] = :"#{subdivision_digit}#{
|
|
72
|
+
# Normalize to the X-form specialty key regardless of concrete skill level
|
|
73
|
+
result[:subcategory] = :"#{subdivision_digit}X#{specific_digit}"
|
|
74
|
+
result[:specialty] = :"#{result[:career_field_subdivision]}X#{specific_digit}"
|
|
55
75
|
|
|
56
|
-
#
|
|
57
|
-
result[:specific_afsc] = :"#{result[:career_field_subdivision]}#{
|
|
76
|
+
# specific_afsc preserves the code exactly as entered (concrete or generic)
|
|
77
|
+
result[:specific_afsc] = :"#{result[:career_field_subdivision]}#{skill_char}#{specific_digit}"
|
|
58
78
|
|
|
59
79
|
# Scan for shredout (optional)
|
|
60
80
|
result[:shredout] = scanner.scan(/[A-Z]/)&.to_sym
|
|
@@ -66,9 +86,8 @@ module GovCodes
|
|
|
66
86
|
end
|
|
67
87
|
end
|
|
68
88
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
DATA = data
|
|
89
|
+
CODES = {}
|
|
90
|
+
private_constant :CODES
|
|
72
91
|
|
|
73
92
|
Code = Data.define(
|
|
74
93
|
:prefix,
|
|
@@ -76,15 +95,30 @@ module GovCodes
|
|
|
76
95
|
:career_field,
|
|
77
96
|
:career_field_subdivision,
|
|
78
97
|
:skill_level,
|
|
98
|
+
:skill_level_number,
|
|
99
|
+
:skill_level_name,
|
|
100
|
+
:specialty,
|
|
101
|
+
:specialty_name,
|
|
79
102
|
:specific_afsc,
|
|
80
103
|
:subcategory,
|
|
81
104
|
:shredout,
|
|
82
|
-
:
|
|
105
|
+
:shredout_name,
|
|
106
|
+
:name,
|
|
107
|
+
:acronym,
|
|
108
|
+
:effective_date
|
|
83
109
|
)
|
|
84
110
|
|
|
85
|
-
|
|
111
|
+
# Resolve an enlisted AFSC against the DAFECD release in effect on +as_of+
|
|
112
|
+
# (default: today). Returns nil when the code does not parse, when the
|
|
113
|
+
# specialty is absent from the resolved release, or when +as_of+ precedes
|
|
114
|
+
# the earliest shipped release (or no release has taken effect yet).
|
|
115
|
+
def self.find(code, as_of: nil)
|
|
86
116
|
code = code.to_s
|
|
87
|
-
|
|
117
|
+
# Key the memo on the RESOLVED release date so equivalent as_of values
|
|
118
|
+
# (nil, the Date, its string form, any date in the same release window)
|
|
119
|
+
# share one slot instead of growing unbounded for time-series callers.
|
|
120
|
+
effective_date = Releases.effective_date_for(as_of: as_of)
|
|
121
|
+
CODES[[code, effective_date]] ||= begin
|
|
88
122
|
parser = Parser.new(code)
|
|
89
123
|
result = parser.parse
|
|
90
124
|
|
|
@@ -101,81 +135,78 @@ module GovCodes
|
|
|
101
135
|
return nil if code.length > 7 ||
|
|
102
136
|
code.match?(/[^A-Z0-9]/)
|
|
103
137
|
|
|
104
|
-
#
|
|
105
|
-
|
|
138
|
+
# Resolve the specialty entry from the versioned, specialty-keyed index
|
|
139
|
+
index = Releases.enlisted_index(as_of: as_of)
|
|
140
|
+
entry = index[result[:specialty]]
|
|
141
|
+
return nil unless entry
|
|
142
|
+
|
|
143
|
+
specialty_name = entry[:name]
|
|
106
144
|
|
|
107
|
-
#
|
|
108
|
-
|
|
145
|
+
# Skill-level title: prefer the directory's title for this specialty,
|
|
146
|
+
# falling back to the universal enlisted skill-level map.
|
|
147
|
+
if (number = result[:skill_level_number])
|
|
148
|
+
result[:skill_level_name] = entry.dig(:skill_levels, number, :title) ||
|
|
149
|
+
SKILL_LEVELS[number]
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Shredout meaning, only when the directory documents this shredout.
|
|
153
|
+
shredout_name = result[:shredout] && entry.dig(:shredouts, result[:shredout])
|
|
109
154
|
|
|
110
|
-
|
|
155
|
+
name = shredout_name || specialty_name
|
|
156
|
+
return nil if name.nil?
|
|
157
|
+
|
|
158
|
+
result[:shredout_name] = shredout_name
|
|
159
|
+
result[:specialty_name] = specialty_name
|
|
111
160
|
result[:name] = name
|
|
161
|
+
# Specialty acronym from the resolved index entry (nil when absent).
|
|
162
|
+
result[:acronym] = entry[:acronym]
|
|
163
|
+
result[:effective_date] = effective_date
|
|
112
164
|
|
|
113
|
-
# Create a new Code object with the result
|
|
114
165
|
Code.new(**result)
|
|
115
166
|
end
|
|
116
167
|
end
|
|
117
168
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
name = lookup_value[:name] || name
|
|
134
|
-
data = lookup_value[:subcategories]
|
|
135
|
-
else
|
|
136
|
-
# String value (leaf node)
|
|
137
|
-
name = lookup_value
|
|
138
|
-
data = nil
|
|
139
|
-
end
|
|
140
|
-
end
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
# Shredout (optional, e.g., :A)
|
|
144
|
-
if data && result[:shredout]
|
|
145
|
-
lookup_value = data[result[:shredout]]
|
|
146
|
-
if lookup_value
|
|
147
|
-
name = lookup_value.is_a?(Hash) ? (lookup_value[:name] || name) : (lookup_value || name)
|
|
148
|
-
end
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
name || "Unknown"
|
|
169
|
+
# Resolve the enlisted specialty whose acronym matches +acronym+
|
|
170
|
+
# (case-insensitive) in the release in effect on +as_of+. Returns the
|
|
171
|
+
# generic (X-form) Code for that specialty, or nil when no specialty in the
|
|
172
|
+
# resolved release carries the acronym. Acronyms come from the shipped,
|
|
173
|
+
# source-verified data and any consumer overlay for that release, so the
|
|
174
|
+
# match is scoped to that release (no leakage across document dates).
|
|
175
|
+
def self.find_by_acronym(acronym, as_of: nil)
|
|
176
|
+
acronym = acronym.to_s.upcase
|
|
177
|
+
return nil if acronym.empty?
|
|
178
|
+
|
|
179
|
+
index = Releases.enlisted_index(as_of: as_of)
|
|
180
|
+
match = index.find { |_specialty, entry| entry[:acronym].to_s.upcase == acronym }
|
|
181
|
+
return nil unless match
|
|
182
|
+
|
|
183
|
+
find(match.first.to_s, as_of: as_of)
|
|
152
184
|
end
|
|
153
185
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
186
|
+
# Clears the memoized lookups and resets the versioned release loader.
|
|
187
|
+
# The +lookup+ keyword is accepted for interface parity with Officer/RI;
|
|
188
|
+
# the versioned index is resolved from the load path at lookup time.
|
|
189
|
+
def self.reset_data(lookup: $LOAD_PATH)
|
|
190
|
+
Releases.reset!
|
|
191
|
+
CODES.clear
|
|
159
192
|
end
|
|
160
193
|
|
|
161
|
-
def self.
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
elsif value.is_a?(String)
|
|
172
|
-
# This is a leaf node (simple string value)
|
|
173
|
-
results << code if code.start_with?(prefix)
|
|
174
|
-
elsif value.is_a?(Hash)
|
|
175
|
-
# Nested subcategories without a name at this level
|
|
176
|
-
collect_codes_recursive(value, current_code, prefix, results)
|
|
194
|
+
def self.search(prefix, as_of: nil)
|
|
195
|
+
prefix = prefix.to_s.upcase
|
|
196
|
+
index = Releases.enlisted_index(as_of: as_of)
|
|
197
|
+
|
|
198
|
+
codes = []
|
|
199
|
+
index.each do |specialty, entry|
|
|
200
|
+
specialty_code = specialty.to_s
|
|
201
|
+
codes << specialty_code
|
|
202
|
+
(entry[:shredouts] || {}).each_key do |shredout|
|
|
203
|
+
codes << "#{specialty_code}#{shredout}"
|
|
177
204
|
end
|
|
178
205
|
end
|
|
206
|
+
|
|
207
|
+
codes.select { |code| code.start_with?(prefix) }
|
|
208
|
+
.map { |code| find(code, as_of: as_of) }
|
|
209
|
+
.compact
|
|
179
210
|
end
|
|
180
211
|
end
|
|
181
212
|
end
|